React شرح useEffectEvent: إيقاف إعادة تشغيل الـ
٨ يونيو ٢٠٢٦
تسمح useEffectEvent، المستقرة منذ React 19.2، لـ Effect بقراءة أحدث الـ props والـ state دون إدراجها كاعتمادات (dependencies) — لذا فإن تغيير السمة (theme) أو الإعدادات لم يعد يؤدي إلى قطع اتصال الـ WebSocket أو المؤقت. يثبت هذا الدليل العملي لـ React useEffectEvent الحل من خلال اختبارات قابلة للتشغيل.
ملخص
يبني هذا الدليل التعليمي لـ React useEffectEvent مكونًا صغيرًا لبث المقاييس المباشرة بثلاث طرق: النسخة الساذجة (naive) التي تعيد الاتصال عند كل تغيير للسمة، والنسخة التي تم كتم تحذيرات الـ lint فيها والتي تعرض بيانات قديمة، ونسخة useEffectEvent التي لا تفعل أيًا منهما. يتم تثبيت كل سلوك بواسطة اختبار Vitest يمكنك تشغيله بنفسك، ورسائل الـ lint المقتبسة هي مخرجات eslint-plugin-React-hooks@7.1.1 حرفيًا. الوقت الإجمالي: حوالي 20 دقيقة. تم تنفيذ كل شيء مقابل React@19.2.7 في يوم الكتابة.
ما ستتعلمه
- لماذا يعيد
useEffectالتشغيل عند كل تغيير للـ prop — ومتى يكون ذلك خطأً وليس ميزة - لماذا يؤدي كتم
exhaustive-depsإلى استبدال تكرار إعادة الاتصال بإغلاقات قديمة (stale closures) - كيفية إصلاح كلتا المشكلتين باستخدام
useEffectEvent، خطوة بخطوة - كيفية ضبط الـ linter بحيث يفرض قواعد Effect Event نيابة عنك
- متى تستخدم
useEffectEventمقابلuseCallback(جدول القرار) - كيفية التحقق من السلوك بالاختبارات بدلاً من الوثوق بمنشور مدونة
المتطلبات الأساسية
- Node.js 24 LTS (يعمل أيضًا Node 20.19+ أو 22.12+ — وهذا هو الحد الأدنى الذي يتطلبه Vite 8 إذا قمت لاحقًا بوضع المكون في تطبيق Vite1)
React@19.2.7وReact-dom@19.2.7— تم إطلاقuseEffectEventكـ API مستقر في React 19.2 (1 أكتوبر 2025)2، لذا فإن أي إصدار 19.2.x سيعمل؛ هذا المنشور يثبت الإصدار الحالي على npm3- TypeScript 6.0.3، Vitest 4.1.8،
@testing-library/React@16.3.2، jsdom 29.1.1 - ESLint 10.4.1 مع
eslint-plugin-React-hooks@7.1.1وTypeScript-eslint@8.60.1
قم بإعداد مشروع بسيط (لا حاجة لخادم تطوير — تعمل أداة الإثبات بدون واجهة رسومية):
mkdir metrics-feed && cd metrics-feed
npm init -y
npm install --save-exact React@19.2.7 React-dom@19.2.7 \
TypeScript@6.0.3 @types/React@19.2.17 @types/React-dom@19.2.3 \
vitest@4.1.8 @testing-library/React@16.3.2 jsdom@29.1.1 \
eslint@10.4.1 eslint-plugin-React-hooks@7.1.1 TypeScript-eslint@8.60.1
اضبط "type": "module" في ملف package.json، بالإضافة إلى ثلاثة سكربتات: "test": "vitest run"، و "typecheck": "tsc --noEmit"، و "lint": "eslint src". إذا كنت تفضل العمل داخل تطبيق كامل، فإن الهيكل القياسي هو npm create vite@latest metrics-feed -- --template React-ts (create-vite 9.0.7)1 — كود المكون أدناه متطابق في كلتا الحالتين.
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "React-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src", "tests"]
}
الخطوة 1 — اتصال وهمي يمكنك مراقبته
لماذا يعيد useEffect التشغيل عند كل تغيير للـ prop؟ لأن كل قيمة يقرأها جسم الـ Effect يجب أن تكون اعتمادًا (dependency)، ويقوم React بإعادة مزامنة الـ Effect كلما تغير أي اعتماد. لرؤية ذلك يحدث، نحتاج إلى نظام خارجي تكون دورة حياته مرئية. تسجل هذه الوحدة كل اتصال وقطع اتصال في سجل مصدّر — يمكن لواجهة المستخدم عرضه، ويمكن للاختبارات التأكد منه.
src/connection.ts:
export type ConnectionEvent = 'connected';
export interface Connection {
on(event: ConnectionEvent, handler: () => void): void;
connect(): void;
disconnect(): void;
}
// مرئي لواجهة المستخدم وللاختبارات: يتم تسجيل كل اتصال/قطع اتصال هنا.
export const connectionLog: string[] = [];
const ACK_DELAY_MS = 300;
export function createConnection(source: string): Connection {
let onConnected: (() => void) | null = null;
let ackTimer: ReturnType<typeof setTimeout> | null = null;
return {
on(event, handler) {
if (event === 'connected') {
onConnected = handler;
}
},
connect() {
connectionLog.push(`connect:${source}`);
ackTimer = setTimeout(() => {
onConnected?.();
}, ACK_DELAY_MS);
},
disconnect() {
if (ackTimer !== null) {
clearTimeout(ackTimer);
}
connectionLog.push(`disconnect:${source}`);
},
};
}
تأخير تأكيد الاتصال (acknowledgment) البالغ 300 مللي ثانية مهم: فهو يخلق نافذة حيث يمكن أن تتغير الـ props بين الاتصال واستدعاء رد الاتصال "connected" — وهو بالضبط المكان الذي تختبئ فيه الإغلاقات القديمة.
الخطوة 2 — النسخة الساذجة: useEffect يعيد التشغيل عند كل تغيير للـ prop
يشترك المكون في source للمقاييس ويعرض إشعارًا (toast) عند الاتصال. يذكر الإشعار الـ theme الحالي. Effect واحد، قيمتان — لكنهما مختلفتان في النوع: source هو منطق الـ Effect (تغييره يجب أن يعيد الاتصال)، بينما يتم قراءة theme فقط بواسطة الحدث الذي ينطلق عندما يتم تأكيد الاتصال.
src/MetricsFeedNaive.tsx:
import { useEffect, useState } from 'React';
import { createConnection } from './connection';
type Theme = 'dark' | 'light';
export function MetricsFeedNaive({ source, theme }: { source: string; theme: Theme }) {
const [toast, setToast] = useState('');
useEffect(() => {
const connection = createConnection(source);
connection.on('connected', () => {
setToast(`Connected to ${source} (${theme} theme)`);
});
connection.connect();
return () => {
connection.disconnect();
};
}, [source, theme]); // theme مطلوب هنا -- وهذا هو الخطأ
return (
<section className={theme}>
<h2>Live metrics: {source}</h2>
{toast && <p role="status">{toast}</p>}
</section>
);
}
مصفوفة الاعتمادات صحيحة وفقًا للقواعد — يتم قراءة theme داخل الـ Effect، لذا يتطلبه exhaustive-deps. وهذا هو بالضبط المشكلة: تبديل السمة يؤدي إلى قطع الاتصال وإعادة بنائه.
حان الوقت لإثبات ذلك. أنشئ tests/feed.test.tsx بهذا الهيكل — فهو يستورد جميع متغيرات المكونات الثلاثة مقدمًا (ستنشئ المتغيرين المتبقيين في الخطوتين 3 و 4؛ تعمل المجموعة في مرحلة التحقق)، ويعيد ضبط السجل قبل كل اختبار، ويستخدم مؤقتات وهمية حتى نتحكم بالضبط في وقت انطلاق تأكيد الـ ack البالغ 300 مللي ثانية:
import { act cleanup render screen } from '@testing-library/React';
import { useEffectEvent } from 'React';
import { afterEach beforeEach expect test vi } from 'vitest';
import { connectionLog } from '../src/connection';
import { MetricsFeed } from '../src/MetricsFeed';
import { MetricsFeedNaive } from '../src/MetricsFeedNaive';
import { MetricsFeedStale } from '../src/MetricsFeedStale';
beforeEach(() => {
vi.useFakeTimers();
connectionLog.length = 0;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
الاختبار الأول يثبت تكرار إعادة الاتصال:
test('naive: a theme change tears down and recreates the connection' () => {
const { rerender } = render(<MetricsFeedNaive source="API" theme="dark" />);
expect(connectionLog).toEqual(['connect:API']);
rerender(<MetricsFeedNaive source="API" theme="light" />);
expect(connectionLog).toEqual(['connect:API' 'disconnect:API' 'connect:API']);
});
استبدل الوهمي بـ WebSocket حقيقي وسيكون هذا هو الخطأ الكلاسيكي "الـ socket الخاص بي يعيد الاتصال عند كل render" — تبديل الإعدادات، أو تغيير اللغة، أو أي prop تجميلي يقطع الاتصال ويعيد بناءه من تحت مستخدميك.
الخطوة 3 — الفخ: كتم قاعدة الـ lint يخلق إغلاقًا قديمًا
الـ "حل" المغري هو حذف theme من المصفوفة وإسكات الـ linter. انسخ MetricsFeedNaive.tsx إلى src/MetricsFeedStale.tsx، وأعد تسمية المكون إلى MetricsFeedStale، وغير فقط الأسطر الختامية للـ Effect:
// eslint-disable-next-line React-hooks/exhaustive-deps
} [source]); // تم حذف theme: لا يوجد تكرار في إعادة الاتصال، لكن الإغلاق يصبح قديمًا
يختفي تكرار إعادة الاتصال — ويظهر خطأ مختلف. تم تشغيل الـ Effect مرة واحدة، عندما كان theme هو dark. قام معالج connected بالإغلاق على theme الخاص بأول render ولم يرَ أيًا غيره أبدًا. يثبت الاختبار أن الإشعار يكذب:
test('stale: suppressing the dep keeps the connection but shows the OLD theme' () => {
const { rerender } = render(<MetricsFeedStale source="API" theme="dark" />);
rerender(<MetricsFeedStale source="API" theme="light" />);
expect(connectionLog).toEqual(['connect:API']); // لا يوجد تكرار...
act(() => {
vi.advanceTimersByTime(300);
});
// ...لكن الإغلاق التقط سمة الـ render الأول
expect(screen.getByRole('status').textContent).toBe('Connected to API (dark theme)');
});
واجهة المستخدم في الوضع الفاتح (light)؛ والإشعار يقول الداكن (dark). تشير ملاحظات إصدار فريق React إلى هذا النمط بالضبط: "معظم المستخدمين يقومون فقط بتعطيل قاعدة الـ lint واستبعاد الاعتماد. لكن ذلك قد يؤدي إلى أخطاء."2
الخطوة 4 — الإصلاح: فصل الحدث عن الـ Effect باستخدام useEffectEvent
تستخرج useEffectEvent الجزء غير التفاعلي إلى Effect Event — وهي وظيفة، وفقًا للوثائق، "تصل دائمًا إلى أحدث القيم المعتمدة من الـ render في وقت الاستدعاء" ويتم استبعادها من اعتمادات الـ Effect.4 (يتم تغطية التفكير وراء هذا الفصل بعمق في دليل React "فصل الأحداث عن الـ Effects".5) توضح ملاحظات الإصدار ذلك ببساطة: "على غرار أحداث الـ DOM، ترى أحداث الـ Effect دائمًا أحدث الـ props والـ state."2
src/MetricsFeed.tsx:
import { useEffect useEffectEvent useState } from 'React';
import { createConnection } from './connection';
type Theme = 'dark' | 'light';
export function MetricsFeed({ source theme }: { source: string; theme: Theme }) {
const [toast setToast] = useState('');
const onConnected = useEffectEvent((connectedTo: string) => {
setToast(`Connected to ${connectedTo} (${theme} theme)`);
});
useEffect(() => {
const connection = createConnection(source);
connection.on('connected' () => {
onConnected(source);
});
connection.connect();
return () => {
connection.disconnect();
};
} [source]);
return (
<section className={theme}>
<h2>Live metrics: {source}</h2>
{toast && <p role="status">{toast}</p>}
</section>
);
}
ثلاثة أشياء يجب ملاحظتها. القيمة التفاعلية (source) تظل اعتمادًا — تغييرها لا يزال يعيد الاتصال. القيمة غير التفاعلية (theme) انتقلت إلى Effect Event ويتم قراءتها طازجة في وقت الاستدعاء. و onConnected نفسها ليست في المصفوفة: يتم استبعاد أحداث الـ Effect من الاعتمادات حسب التصميم، والـ linter يعرف ذلك.4
كلا نصفي الإصلاح قابلان للاختبار الآن:
test('useEffectEvent: no reconnect AND the latest theme' () => {
const { rerender } = render(<MetricsFeed source="API" theme="dark" />);
rerender(<MetricsFeed source="API" theme="light" />);
expect(connectionLog).toEqual(['connect:API']); // اتصال واحد
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.getByRole('status').textContent).toBe('Connected to API (light theme)');
});
test('useEffectEvent: changing source still reconnects (stays reactive)' () => {
const { rerender } = render(<MetricsFeed source="API" theme="dark" />);
rerender(<MetricsFeed source="db" theme="dark" />);
expect(connectionLog).toEqual(['connect:API' 'disconnect:API' 'connect:db']);
});
اتصال واحد، سمة حديثة، وتفاعلية محفوظة حيث تنتمي.
الخطوة 5 — ضبط الـ linter بحيث تفرض القواعد نفسها
تُفرض قيود Effect Event بواسطة eslint-plugin-React-hooks6 — ولكن فقط إذا تم تحميل إعداداتك (config) بالفعل. الإصدار 7.1.1 يعرض كلاً من الإعدادات المسبقة (presets) القديمة والمسطحة (flat)، وقد أزال ESLint 10 نظام .eslintrc القديم تماماً7 — الإعداد المسطح (flat config) هو الوحيد الذي يقبله — لذا يجب عليك استخدام إعدادات flat المسبقة. يشير ملف README الخاص بالحزمة لمستخدمي flat-config إلى configs.flat.recommended؛ أما الإعداد المسبق المنفصل recommended-latest فهو مخصص لـ "قواعد المترجم التجريبية المتطورة"، لذا التزم بـ recommended هنا.
eslint.config.js:
import reactHooks from 'eslint-plugin-React-hooks';
import tseslint from 'TypeScript-eslint';
export default tseslint.config(
{
files: ['src/**/*.{ts,tsx}'],
extends: [
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
],
},
);
مع تفعيل ذلك، ينجح npx eslint src في فحص مكون الخطوة 4 — حيث لا تطلب exhaustive-deps لا theme ولا onConnected. الآن قم بكسر القواعد عمداً. أدرج Effect Event كاعتمادية (dependency):
useEffect(() => {
onLog();
}, [onLog]); // effect event listed as a dependency
وسترد الإضافة بتحذير (القاعدة: React-hooks/exhaustive-deps):
Functions returned from `useEffectEvent` must not be included in the
dependency array. Remove `onLog` from the list
هذا ليس تدقيقاً زائداً. هوية Effect Event تتغير عمداً مع كل عملية رندر — تصف الوثائق الهوية غير المستقرة بأنها "تأكيد وقت التشغيل" (runtime assertion) — لذا فإن الاعتماد عليها سيؤدي لإعادة تشغيل الـ Effect مع كل رندر.4 جرب استدعاء واحدة من معالج نقر (click handler) بدلاً من ذلك:
return <button onClick={() => onLog()}>log</button>;
وستحصل على خطأ جسيم (القاعدة: React-hooks/rules-of-hooks):
`onLog` is a function created with React Hook "useEffectEvent", and can
only be called from Effects and Effect Events in the same component
كلتا الرسالتين أعلاه هما مخرجات ESLint حرفية من eslint-plugin-React-hooks@7.1.1.
useEffectEvent مقابل useCallback: أيهما تحتاج؟
هل useEffectEvent بديل لـ useCallback؟ لا — فهما يحلان مشكلتين متضادتين. useCallback يمنحك هوية مستقرة يمكنك تمريرها؛ بينما يمنحك Effect Event قيماً حديثة داخل الـ Effects ولا يمكنه مغادرة المكون. الوثائق صريحة: بالنسبة لردود الفعل (callbacks) التي تمررها للأبناء أو تستدعيها من معالجات الأحداث، "استخدم دالة عادية أو useCallback بدلاً من ذلك."4
| أنت بحاجة إلى... | استخدم |
|---|---|
| قراءة أحدث الـ props/state داخل Effect دون إعادة تشغيله | useEffectEvent |
| دالة بهوية مستقرة لتمريرها إلى مكون ابن محفوظ (memoized) | useCallback |
| إعادة تشغيل الـ Effect عند تغير قيمة ما (هذا منطق الـ Effect) | أبقِها في مصفوفة الاعتماديات |
| رد فعل (callback) لمعالج حدث أو لمكون/Hook آخر | دالة عادية أو useCallback |
إسكات exhaustive-deps لأن قائمة الاعتماديات "تبدو خاطئة" | لا شيء مما سبق — أعد الهيكلة؛ إخفاء الاعتماديات يخفي الأخطاء البرمجية4 |
قاعدة الحدود: لا يمكن التصريح عن Effect Events إلا في نفس المكون أو الـ Hook الخاص بالـ Effect التابع لها، ولا يمكن تمريرها إلى مكونات أو Hooks أخرى، ولا يمكن استدعاؤها أثناء الرندر.4 وهي تعمل داخل الـ Hooks المخصصة — حيث يقوم useInterval في الوثائق بتغليف رد الفعل الوارد في Effect Event بحيث لا يؤدي رد الفعل الجديد في كل رندر إلى إعادة ضبط المؤقت (interval).4
التحقق
اختبار أخير يكمل المجموعة — حارس وقت الرندر. أضفه إلى tests/feed.test.tsx:
function CallsDuringRender() {
const onPing = useEffectEvent(() => {});
onPing(); // wrong on purpose
return null;
}
test('calling an effect event during render throws', () => {
expect(() => render(<CallsDuringRender />)).toThrowError();
});
ثم قم بتشغيل مجموعة الاختبارات بالكامل:
npx tsc --noEmit && npx vitest run
المخرجات المتوقعة (مختصرة):
✓ tests/feed.test.tsx (5 tests)
Test Files 1 passed (1)
Tests 5 passed (5)
خمسة اختبارات ناجحة: تقلب إعادة الاتصال في النسخة البدائية، التنبيه (toast) القديم في النسخة المكبوتة، الاتصال الفردي مع الثيم الحديث باستخدام useEffectEvent، إعادة الاتصال عند تغيير المصدر، وحارس وقت الرندر أعلاه. ثم npx eslint src لعملية فحص نظيفة.
استكشاف الأخطاء وإصلاحها
"A function wrapped in useEffectEvent can't be called during rendering." — لقد استدعيت Effect Event في جسم المكون. هذا خطأ في وقت التشغيل (تم التحقق منه في React@19.2.7؛ اختبارنا الخامس يؤكد ذلك). انقل الاستدعاء إلى useEffect — أو إذا كان المنطق يجب أن يعمل أثناء الرندر، فهو ليس حدثاً (event)؛ فلا تقم بتغليفه.4
"Functions returned from useEffectEvent must not be included in the dependency array." — تحذير من React-hooks/exhaustive-deps. قم بإزالة Effect Event من الاعتماديات؛ فهي مستبعدة حسب التصميم.4
"...can only be called from Effects and Effect Events in the same component." — خطأ من React-hooks/rules-of-hooks. لقد استدعيت Effect Event من معالج حدث أو مررتها إلى مكون ابن. استخدم دالة عادية أو useCallback لهذه المسارات.4
يتوقف ESLint مع رسالة: A config object has a "plugins" key defined as an array of strings. — لقد قمت بتحميل إعداد مسبق بتنسيق قديم (مثل reactHooks.configs['recommended-latest'] في 7.1.1) في ESLint ذو الإعداد المسطح (flat-config). استخدم reactHooks.configs.flat.recommended كما في الخطوة 5.
TypeScript: Module '"React"' has no exported member 'useEffectEvent'. — إصدار React أو @types/React لديك أقدم من 19.2. قم بترقية كليهما (وقت تشغيل React@19.2.x و @types/React@19.2.x — يتم إصدار النسختين بشكل مستقل على npm).
الخطوات التالية
إذا كانت أساسيات useEffect هي الجزء غير المستقر لديك، فابدأ بـ دليل useEffect حول مصفوفات الاعتماديات والتنظيف ثم عد إلى هذا الدرس التعليمي حول React useEffectEvent. لمزيد من ممارسات React 19 العملية، راجع Server Actions وواجهة المستخدم المتفائلة مع React 19 في Next.js 16؛ كما يظهر useEffectEvent في ملخص التحضير لمقابلات React لميزات 19.2. من هنا، هناك توسعان طبيعيان: تغليف هذا النمط في Hook مخصص قابل لإعادة الاستخدام useConnection(source) (تعمل Effect Events داخل الـ Hooks المخصصة4)، واستكشاف ، وهي الـ API البارزة الأخرى في React 19.2.2
الحواشي
-
Vite — البداية — أمر الإنشاء ومتطلبات Node.js 20.19+ / 22.12+. ↩ ↩2
-
منشور إصدار React 19.2 (1 أكتوبر 2025) —
useEffectEvent، و<Activity />، وcacheSignal، وملاحظات eslint-plugin-React-hooks v6. ↩ ↩2 ↩3 ↩4 -
React على npm — كان الإصدار 19.2.7 هو أحدث إصدار مستقر وقت الكتابة (نُشر في 1 يونيو 2026، وفقاً لسجل npm). ↩
-
useEffectEvent — مرجع React الرسمي — API، والتحذيرات، ودلالات الهوية، ومدخلات استكشاف الأخطاء وإصلاحها. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
فصل الأحداث عن التأثيرات — React.dev — التعمق المفاهيمي وراء أحداث التأثير (Effect Events). ↩
-
eslint-plugin-React-hooks — مرجع القواعد الرسمي — الإعداد المسبق الموصى به، و
exhaustive-deps، وrules-of-hooks. ↩ -
إصدار ESLint v10.0.0 — تمت إزالة نظام التكوين القديم
.eslintrcفي الإصدار v10؛ يتم دعم التكوين المسطح (flat config) فقط. ↩