خلّي الـ UI يبان أسرع من الـ Server مع Optimistic Updates في React
إزاي منصات السوشيال ميديا بتخليك حاسس إن الـ Like بيحصل فورًا؟ في المقال ده هنفهم مفهوم Optimistic Updates، ونطبقه عمليًا في Next.js باستخدام useOptimistic و useActionState.
جدول المحتويات
Optimistic Updates: إزاي تخلي الـ UI يبان أسرع من الـ Server
الكود الكامل
الكود بتاع المثال العملي في المقال ده موجود بالكامل في الريبو ده: optimistic-update-blog
عمرك ماسألت نفسك سؤال: ازاي لما تعمل Like على أي منصة Social Media، بتلاقيه اتعمل في نفس الثانية اللي انت ضغطت فيها؟
الحقيقة إن انت أول ما بتضغط زرار الـ Like، هو بيحط الـ Like على طول في الـ Client، بس على الـ Server هو بياخد وقت لحد ما الـ Like يتسجل فعلًا في الـ Database.
![]()
ودي هي فكرة Optimistic Update، وهي بكل بساطة إنك بتكون Optimistic (متفائل) إن العملية دي هتتم بنجاح على الـ Server، فأنت بتجهّز الـ UI أو الـ Client في حالة إن العملية دي نجحت.
طيب لو حصلت مشكلة قبل ما العملية تخلص؟ هنا بييجي دور الـ Fallback: بنرجّع الـ UI للحالة الأصلية بتاعته قبل ما نبدأ العملية اللي كنا هنعملها. يعني مثال الـ Like: عملت Like، بس حصلت مشكلة وما اتحفظش على الـ Server (يعني ما اتخزنش في الـ Database)، هنا بنشيل علامة الـ Like اللي اتحطت في الـ UI، وده هو المقصود بالـ Fallback.
ده هو السر اللي غالب الـ Applications بتستخدمه عشان تبان سريعة وسلسة جدًا في الاستخدام. تخيّل شكل الـ Like لو بيقعد ثانية أو اتنين لحد ما يظهر إنك عملت Like مش هتحس إنها عملية 😅. فعلشان كده بنعمل Optimistic Updates للحاجات اللي زي كده.
يعني إيه Optimistic Update بالظبط
عشان تعمل أي Optimistic Update، لازم تكون عارف حاجة مهمة جدًا: إحنا عاوزين نحدد شكل الصفحة بعد ما العملية تبدأ، مش بعد ما تخلص.
مثال: عندك منتجين ظاهرين في الصفحة، وروحت ضفت منتج تالت. شكل الصفحة ببساطة هيبقى فيه المنتجين + المنتج التالت اللي انت لسه ضايفه، من غير ما تستنى رد الـ Server.
ولو الـ Server حصل فيه مشكلة، بنشيل المنتج اللي اتحط، عشان لو سبناه هيبقى Stale State يعني الـ Data اللي ظاهرة قدامك في الـ Client مش نفسها اللي فعلًا موجودة على الـ Server.
ملحوظة
مهما كان الـ Tool اللي هتستخدمه، الفكرة واحدة دايمًا: انت بتتوقع شكل الـ Data هيبقى إيه بعد العملية، وخلاص.
الأداة اللي هنستخدمها: useOptimistic
في React في Hook اسمه useOptimistic مصمم مخصوص للحالة دي. هنشوف إزاي نطبقه عمليًا دلوقتي من خلال مثال Product Form بسيط: هنعمل Form بتضيف Product جديد، ونخليه يظهر في الـ List فورًا (Optimistically) قبل ما الـ Server يرد.
هنبني إيه في المقال ده
ده شكل المشروع اللي هنشتغل بيه:
شكل الـ Data اللي هنشتغل بيها
كل Product عندنا شكله كده:
[
{
"id": "1",
"title": "Developer Cloud",
"status": "development",
"description": "A cloud workspace for deploying, monitoring, and managing developer projects."
},
{
"id": "5",
"title": "Project Management",
"status": "active",
"description": "A collaborative workspace for managing projects, tasks, and team progress."
}
]عندنا 3 Status: active | development | archived، وكل واحدة فيهم مرتبطة بـ Icon مختلفة.
أيقونة لكل Status
import { LucideArchive, LucideCircleCheckBig, LucideClock } from 'lucide-react';
<!-- prettier-ignore -->
export const STATUS = {
ACTIVE: LucideCircleCheckBig,
DEVELOPMENT: LucideClock,
ARCHIVED: LucideArchive,
} as const; خلي بالك
إحنا هنا بنخزن الـ Component نفسه (LucideCircleCheckBig)، مش الـ JSX
Element الجاهز (زي LucideCircleCheckBig() بعد ما بيتنادى). الفرق ده مهم: لو
خزّنت JSX Element جاهز، مش هتقدر تمرّرله Props زي className وقت الاستخدام.
لكن لو خزّنت الـ Component نفسه، تقدر تستخدمه زي أي Component عادي وتديله أي
Props محتاجها.
عرض المنتجات: ProductCard و ProductList
الـ Card بتاعة كل Product:
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type LucideIcon } from "lucide-react";
type ProductCardProps = {
title: string;
description: string;
StatusIcon: LucideIcon;
};
export default function ProductCard({
title,
description,
StatusIcon,
}: ProductCardProps) {
return (
<Card className="max-w-125 w-full">
<CardHeader>
<CardTitle className="flex gap-2 text-lg items-center">
<div className="bg-primary/10 p-2 rounded-md">
<StatusIcon />
</div>
<p>{title}</p>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
</Card>
);
}وبما إننا بنستخدم JSON Data، في الإصدارات الحديثة من JavaScript إحنا نقدر نعمل Import لملف JSON عادي زي أي Module تاني:
import products from "@/data/products.json";
import { STATUS } from "@/constants";
import ProductCard from "@/features/products/components/product-card";
export default function ProductList() {
return products.map((product) => (
<ProductCard
key={product.id}
title={product.title}
description={product.description}
StatusIcon={STATUS[product.status.toUpperCase() as keyof typeof STATUS]}
/>
));
}الإصدار ده كويس، بس فيه مشكلة: هو بيقرا الـ Data من الملف مباشرة، وده معناه إننا مش هنقدر نتحكم فيه من بره لما نيجي نعمل الـ Optimistic Update. هنرجعله تاني بعد شوية.
عمل الـ Form: FormField
عملت Component صغير قابل لإعادة الاستخدام اسمه FormField:
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useId } from "react";
type FormFieldProps = {
label: string;
name: string;
placeholder?: string;
};
export default function FormField({
name,
label,
placeholder,
}: FormFieldProps) {
const id = useId();
return (
<>
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="text"
name={name}
placeholder={placeholder || ""}
required
/>
</>
);
}استعملت فيه حاجة اسمها useId()، وهي Hook بتعملك Unique ID تلقائيًا. بدل ما تربط الـ Label بالـ Input بـ id مكتوب بإيدك (وممكن يتكرر لو استخدمت الـ Component أكتر من مرة في نفس الصفحة)، بتستخدم useId() وهي هتعمل الـ id ده على طول. تقدر تقرا عنها أكتر هنا.
مشكلة الزرار اللي برا الـ Form
دلوقتي خلينا نبني الـ Form بتاعة إنشاء الـ Product. لو كتبتها بالشكل البسيط ده:
export function ProductForm() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Create Product</CardTitle>
</CardHeader>
<CardContent>
<form>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<FormField
label="Product Name"
name="title"
placeholder="Enter your product name"
/>
<FormField
label="Product Description"
name="description"
placeholder="Enter product's description"
/>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button type="submit" className="w-full">
Create Product
</Button>
</CardFooter>
</Card>
);
}هتلاقي إن الـ Button موجود جوّه الـ CardFooter، برا الـ form نفسه. يعني لو دست Submit، مفيش حاجة هتحصل الزرار مش شايف الـ Form أصلًا.
الحل: ناخد الـ id بتاع الـ Form ونربطه بالـ Button باستخدام الـ Attribute اسمه form. وطبعًا هنستخدم useId() تاني عشان نولّد الـ id ده:
import { useId } from "react";
export function ProductForm() {
const formId = useId();
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Create Product</CardTitle>
</CardHeader>
<CardContent>
<form id={formId}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<FormField
label="Product Name"
name="title"
placeholder="Enter your product name"
/>
<FormField
label="Product Description"
name="description"
placeholder="Enter product's description"
/>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button form={formId} type="submit" className="w-full">
Create Product
</Button>
</CardFooter>
</Card>
);
}دلوقتي الـ Button بقى مرتبط بالـ form حتى لو كان برا الـ <form> Element نفسه.
الـ Server Action: إنشاء Product جديد
دلوقتي هنعمل الجزء اللي هيحاكي POST Request بتاعنا، وهيكون Server Action:
'use server';
import products from '@/data/products.json';
import { writeFile } from 'fs/promises';
export type ActionType = {
status?: 'success' | 'error' | null;
message?: string;
data: typeof products;
};
export async function createProduct(
prevState: ActionType,
formObject: (typeof products)[number],
): Promise<ActionType> {
if (!formObject.title || !formObject.description || !formObject.status)
return {
...prevState,
status: 'error',
message: 'there is missing fields',
};
// artificial delay
await new Promise(resolve => setTimeout(resolve, 2 * 1000));
products.unshift({ ...formObject });
await writeFile(
'./src/data/products.json',
JSON.stringify(products, null, 2),
);
return {
...prevState,
status: 'success',
message: 'product created successfully',
data: [{ ...formObject }, ...prevState.data],
};
}ملحوظة مهمة
الـ Action هنا مبيستقبلش FormData خام، لأن استخراج الـ Fields من الـ
FormData هيحصل في الـ Client قبل ما نستدعي الـ Action (هنشوف ده دلوقتي).
كمان الـ writeFile هنا مجرد Trick لأغراض الـ Demo عشان نخلي التعديل يفضل
محفوظ بعد الـ Refresh
فيه Artificial Delay بثانيتين علشان نحس بالفرق بين اللي بيحصل في الـ UI فورًا (Optimistic) واللي بيحصل فعلًا على الـ Server.
تجميع كل حاجة: useActionState + useOptimistic
دلوقتي نعدّل الـ ProductForm عشان تستقبل الـ Action، وتحوّل الـ FormData لـ Object قبل ما تبعتها:
"use client";
import FormField from "@/components/form-field";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { useId } from "react";
import type data from "@/data/products.json";
import products from "@/data/products.json";
type ProductFormProps = {
action: (formData: (typeof products)[number]) => void;
handleOptimistic: (product: (typeof data)[number]) => void;
isPending: boolean;
};
export default function ProductForm({
action,
handleOptimistic,
isPending,
}: ProductFormProps) {
const formId = useId();
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Create Product</CardTitle>
</CardHeader>
<CardContent>
<form
action={async (formData) => {
const formObject = Object.fromEntries(
formData,
) as (typeof products)[number];
const newProduct = {
id: crypto.randomUUID(),
title: formObject.title,
description: formObject.description,
status: formObject.status,
};
handleOptimistic(newProduct);
action(newProduct);
}}
id={formId}
>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<FormField
label="Product Name"
name="title"
placeholder="Enter your product name"
/>
<FormField
label="Product Description"
name="description"
placeholder="Enter product's description"
/>
<Label htmlFor="status">Status</Label>
<Select id="status" name="status">
<SelectTrigger className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={"active"}>Active</SelectItem>
<SelectItem value={"development"}>Development</SelectItem>
<SelectItem value={"archived"}>Archived</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button
form={formId}
type="submit"
className="w-full"
disabled={isPending}
>
{isPending ? (
<>
<Spinner />
Create Product
</>
) : (
"Create Product"
)}
</Button>
</CardFooter>
</Card>
);
}ملحوظة مهمة جدًا
أي action بتحطه في الـ action Prop بتاع الـ form بيكون By Default
جوّه Transition. المعلومة دي أساسية جدًا وهنبني عليها في استخدام
useOptimistic، لأن الـ Optimistic Data مش هتظهر إلا لما الـ Action يكون
فعليًا جوّه Transition. والـ Optimistic Data دي هي اللي إحنا بنحطها في الـ
Setter (setOptimisticProduct)، عشان React تعرضها على طول، ونفضل شايفين الـ
Optimistic Data دي لحد ما الـ Transition يخلص.
دلوقتي لازم نعدّل الـ ProductList عشان تستقبل الـ products كـ Prop بدل ما
تقراها من الملف مباشرة، عشان نقدر نمررلها الـ Optimistic Data:
import products from "@/data/products.json";
import { STATUS } from "@/constants";
import ProductCard from "@/features/products/components/product-card";
type ProductListProps = {
products: typeof products;
};
export default function ProductList({ products }: ProductListProps) {
return products.map((product) => (
<ProductCard
key={product.id}
title={product.title}
description={product.description}
StatusIcon={STATUS[product.status.toUpperCase() as keyof typeof STATUS]}
/>
));
}وأخيرًا، الـ Component اللي بيجمع كل حاجة سوا:
"use client";
import products from "@/data/products.json";
import { ActionType, createProduct } from "@/features/products/actions";
import ProductForm from "@/features/products/components/product-form";
import ProductList from "@/features/products/components/products-list";
import { useActionState, useOptimistic } from "react";
const initialActionState: ActionType = {
data: products,
};
export default function Products() {
const [state, createActionProduct, isPending] = useActionState(
createProduct,
initialActionState,
);
const [optimisticProduct, setOptimisticProduct] = useOptimistic(state);
function handleOptimisticProduct(product: (typeof products)[number]) {
setOptimisticProduct({ data: [{ ...product }, ...state.data] });
}
return (
<>
<ProductForm
action={createActionProduct}
handleOptimistic={handleOptimisticProduct}
isPending={isPending}
/>
<ProductList products={optimisticProduct.data} />
</>
);
}خلّينا نفكك اللي بيحصل هنا:
useActionState(createProduct, initialActionState): بيدينا الـstateالحالي، ودالة (createActionProduct) هنمررها للـform، وقيمةisPendingبتقولنا الـ Action لسه شغالة ولا خلصت.useOptimistic(state): بياخد الـstateالحقيقي كـ Base، وبيدينا نسخةoptimisticProductمنه. لما تكون في Transition شغالة،optimisticProductبيبقى هو الـ Value اللي انت مديته لـsetOptimisticProduct، ولحد ما الـ Transition تخلص، هو بيرجع تلقائيًا للـ Value الحقيقي اللي جاي من الـ Server.handleOptimisticProduct: هي اللي بتحط الـ Product الجديد فوق الـ List الحالية فورًا، قبل ما نستنى رد الـ Server خالص.
تجربة النتيجة
دلوقتي لو دخلت اسم Product وضغطت Create، هتلاقي المنتج الجديد بيظهر في الـ List على طول، مع إن الـ Server لسه مستني ثانيتين (الـ Artificial Delay) قبل ما يرد فعلًا.

وده بالظبط نفس المبدأ اللي شرحناه في الأول مع مثال الـ Like بس هنا بدل ما إحنا مضطرين نكتب Logic يدوي لعمل الـ Fallback، React بتتكفل بيه لينا: بمجرد ما الـ Transition تخلص، optimisticProduct بيرجع تلقائيًا يطابق الـ state الحقيقي.
رحلة الـ Optimistic Update
المستخدم بيعمل Submit
الـ form action بيتنفذ جوّه Transition تلقائيًا.
الـ UI بيتحدث فورًا
بنستدعي handleOptimistic عشان نحط الـ Product الجديد في الـ List على طول، من غير ما نستنى الـ Server.
الـ Server Action بتشتغل في الخلفية
createProduct بتتنفذ (فيها الـ Artificial Delay هنا)، وبتحاول تحفظ الـ Product فعلًا.
الـ Transition بتخلص
useOptimistic بيرجع تلقائيًا للـ state الحقيقي اللي رجع من الـ Server.
لو حصلت مشكلة
الـ state الحقيقي هيفضل زي ما هو من غير المنتج الجديد، يعني الـ UI هيرجع لحالته الأصلية تلقائيًا (Fallback) من غير ما تكتب Logic إضافي.
الخلاصة
الفكرة في النهاية بسيطة: بدل ما تخلّي المستخدم مستني رد الـ Server عشان يشوف نتيجة أي عملية، انت بتفترض إن العملية هتنجح، وبتحدّث الـ UI على طول. ولو حصلت مشكلة، بترجّع الحالة زي ما كانت.
في React، useOptimistic بيدّيك الطريقة الرسمية لعمل ده جوّه Transition، مع useActionState عشان تدير الـ Server Action وحالة الـ Pending في نفس الوقت.
لو لقيت أي نقطة محتاجة تصحيح أو عندك إضافة، شاركني بيها ❤️