Web Totals

Học từ Facebook #43: Composer dạng plugin, mỗi plugin tự lo phần mình

Ô đăng bài của Facebook giữ một state duy nhất; mỗi plugin tự đăng ký reducer, dirty checker, validator và hook trước khi đăng, không plugin nào biết nhau.

hunghg255hunghg25526 min read
#facebook#react#architecture#state#forms
Học từ Facebook #43: Composer dạng plugin, mỗi plugin tự lo phần mình

Ô "Bạn đang nghĩ gì?" của Facebook trông như một textarea, nhưng bên trong là hàng chục tính năng: ảnh, video, nền màu, gắn thẻ bạn bè, cảm xúc, check-in, link preview, chọn đối tượng xem… Mỗi tính năng do một nhóm khác nhau làm, bật tắt theo quốc gia, theo loại tài khoản, theo thử nghiệm A/B.

Viết composer theo kiểu thông thường thì component cha biết hết: một useReducer lớn với case 'ADD_PHOTO', case 'SET_BACKGROUND'…, một hàm isDirty() so từng trường, một hàm validate() kiểm từng điều kiện. Mỗi plugin mới là một lần sửa file trung tâm, và gỡ một plugin thì phải nhớ gỡ ở năm chỗ.

Tip

Tóm tắt

  • CometComposer của Facebook giữ một view state. Mỗi plugin tự đăng ký reducer, "dirty checker", validator và pre-submit hook qua context. Composer không có danh sách plugin nào.
  • Việc đăng ký có đếm tham chiếu: hai component dùng chung một hàm chỉ chạy một lần, và hàm chỉ bị gỡ khi lần đăng ký cuối cùng được gỡ.
  • isDirty là phép OR các checker: mỗi plugin chỉ trả lời "phần của tôi đã bị sửa chưa". Pre-submit hook nối thành một chuỗi Promise, hook nào cũng có thể mở hộp thoại rồi mới quyết định cho đăng hay dừng.
  • Bản gốc đăng ký chính hàm plugin truyền vào. Plugin truyền hàm inline thì mỗi lần render, hàm bị gỡ rồi thêm lại ở cuối danh sách, nên thứ tự reducer chạy phụ thuộc thứ tự render. Bản viết lại sửa bằng wrapper ổn định.

#Facebook làm thế nào?

Các module nằm trong bundle 15.js và 6.js (cùng được gộp vào UFIReactionsDialogLayerImpl.react.js): CometComposer.react, reduceComposerViewState, useComposerViewStateReducer, useContextRef, getComposerPluginEligibility.

#Một state, bốn sổ đăng ký

CometComposer tạo bốn useRef(new Map()) và đưa mỗi cái vào một context riêng:

const reducers = useRef(new Map());       // CometComposerViewStateReducersContext
const alteredCheckers = useRef(new Map()); // CometComposerViewStateAlteredContext
const validationErrors = useRef({});       // CometComposerValidationErrorsContext
const preSubmitHooks = useRef(new Map());  // CometComposerPreSubmitHooksContext
js

Context chứa ref chứ không chứa Map. Plugin thêm hay bớt hàm thì chỉ sửa Map bên trong, giá trị context không đổi, nên không component nào phải render lại.

Plugin đăng ký qua một hook nhỏ, useContextRef:

function useContextRef(fn, Context) {
  const map = useContext(Context).current;
  useEffect(() => {
    map.set(fn, (map.get(fn) ?? 0) + 1);             // mount: +1
    return () => {
      const n = map.get(fn);
      if (n === 1) map.delete(fn); else map.set(fn, n - 1); // unmount: -1, về 0 thì xoá
    };
  }, [map, fn]);
}
js

Map ở đây dùng hàm làm key, số đếm làm value. Hai component cùng đăng ký một hàm thì hàm đó vẫn chỉ có một entry, đếm bằng 2. Đây là đếm tham chiếu (reference counting), giống cách hệ điều hành quản lý file handle.

useComposerViewStateReducer(reducer, alteredChecker) gọi useContextRef hai lần: một cho reducer, một cho checker. Plugin không có checker thì nhận một hàm mặc định () => false.

#Reducer chạy lần lượt, isDirty là OR

reduceComposerViewState được gọi bên trong reducer của useReducer:

function reduceComposerViewState(reducersRef, checkersRef, state, unalteredBeginning, action) {
  let next = state;
  for (const reducer of reducersRef.current.keys()) next = reducer(next, action);
  return {
    ...next,
    isDirty: Array.from(checkersRef.current.keys()).some((check) => check(unalteredBeginning, next)),
  };
}
js

Mọi action đi qua mọi reducer, theo thứ tự chèn của Map. Plugin nền màu có thể phản ứng với action gõ chữ của plugin text (ví dụ: chữ dài quá thì tắt nền), mà không cần plugin text biết nó tồn tại.

isDirty không do composer tự so sánh. Mỗi plugin cung cấp một checker chỉ so phần của mình: CometFormattedBackgroundAlterationStateChecker chỉ so preset nền màu. Cờ này điều khiển CometOnBeforeUnload, hộp "Bạn có chắc muốn rời trang?".

#Ổn định tham chiếu để context không làm render thừa

Lỗi validate và tập plugin "không đủ điều kiện" được tính lại mỗi khi state đổi, nhưng chỉ thay tham chiếu khi nội dung thật sự khác:

const errors = useMemo(() => getComposerValidationErrors(validators, viewState), [validators, viewState]);
shallowEqual(errorsRef.current, errors) || (errorsRef.current = errors);
// tương tự với Set plugin không đủ điều kiện: cùng size và cùng phần tử thì giữ Set cũ
js

Consumer của CometComposerValidationErrorsContext chỉ render lại khi danh sách lỗi đổi, không phải mỗi lần gõ phím.

#Submit: chuỗi hook có thể dừng

const onSubmit = (e) => {
  e?.type !== 'press' && e?.preventDefault();
  if (Object.keys(errorsRef.current).length > 0) return onValidationErrors?.(errorsRef.current);
  const state = latestStateRef.current;
  Array.from(preSubmitHooks.current.keys())
    .reduce((chain, hook) => chain.then(() => new Promise((onContinue, onHalt) => hook({ onContinue, onHalt }, state))),
            Promise.resolve())
    .then(() => onSubmitCommit(state))
    .catch(() => onSubmitHalt?.());
};
js

Mỗi hook nhận onContinue và onHalt (thực chất là resolve và reject của một Promise). Hook có thể mở hộp thoại "Đăng mà không gắn thẻ địa điểm?" và chỉ gọi onContinue khi người dùng bấm đồng ý. Hook sau chỉ được hỏi khi hook trước đã cho qua. Composer còn chèn <input type="submit" style="display:none"> vào form để phím Enter vẫn kích hoạt submit.

#Những chỗ dễ gãy

  • Hàm inline làm đổi thứ tự reducer. useContextRef có deps [map, fn]. Plugin truyền useComposerViewStateReducer((s, a) => …) thì fn là hàm mới sau mỗi render, effect chạy lại: gỡ hàm cũ (đếm về 0, xoá khỏi Map) rồi thêm hàm mới vào cuối Map. Plugin nào vừa render lại thì reducer của nó tụt xuống cuối. Thứ tự reducer chạy vì thế phụ thuộc thứ tự render, điều mà plugin không kiểm soát. Demo tái hiện được: bật "Đăng ký kiểu bản gốc", focus vào ô chữ (chỉ plugin chữ render lại), thứ tự chuyển từ chữ → nền → ảnh sang nền → ảnh → chữ. Với các plugin thật của Facebook mình không kiểm được plugin nào truyền hàm inline, nên đây là rủi ro của thiết kế chứ chưa chắc là lỗi đang xảy ra.
  • Checker mặc định cũng là hàm mới mỗi render. Plugin không truyền checker nhận function () { return false } tạo mới mỗi lần gọi hook, nên checker mặc định bị gỡ và đăng ký lại sau mỗi render. Vô hại về kết quả (luôn false), nhưng là việc thừa.
  • Reducer có side effect. Reducer truyền cho useReducer gán latestStateRef.current = next và gọi onBeforeViewStateChange. React StrictMode gọi reducer hai lần ở môi trường dev, nên callback này chạy hai lần.
flowchart TB
  subgraph Plugins["Plugin (không biết nhau)"]
    T["Chữ: reducer + checker + validator"]
    B["Nền màu: reducer + checker"]
    P["Ảnh: reducer + checker + pre-submit hook"]
  end
  T & B & P -- "đăng ký có đếm tham chiếu" --> R["Sổ đăng ký: Map hàm → số đếm"]
  A["dispatch(action)"] --> Run["Chạy mọi reducer theo thứ tự chèn"]
  R --> Run
  Run --> D["isDirty = OR các checker"]
  Run --> V["lỗi = gộp các validator"]
  S["Bấm Đăng"] --> V2{"Có lỗi?"}
  V2 -- có --> Stop["onValidationErrors"]
  V2 -- không --> H["Hook 1 → Hook 2 → …"]
  H -- "tất cả onContinue" --> C["onSubmitCommit(state)"]
  H -- "một hook onHalt" --> X["onSubmitHalt"]

#Viết lại

Bản viết lại tách phần lõi khỏi React: một class ComposerStore giữ state và bốn sổ đăng ký, React chỉ đọc qua useSyncExternalStore. Lõi vì thế test được mà không cần render.

Sổ đăng ký có đếm tham chiếu:

src/lib/pluginComposer.ts
/**
 * Tập có đếm tham chiếu: cùng một hàm được đăng ký 2 lần (2 component dùng chung) thì
 * chỉ chạy 1 lần, và chỉ bị gỡ khi lần đăng ký cuối cùng được gỡ. Thứ tự = thứ tự đăng ký đầu.
 */
export class RefCountedRegistry<F> {
  private counts = new Map<F, number>();

  add(item: F): () => void {
    this.counts.set(item, (this.counts.get(item) ?? 0) + 1);
    let released = false;
    return () => {
      if (released) return;
      released = true;
      const n = this.counts.get(item);
      if (n === undefined) return;
      if (n <= 1) this.counts.delete(item);
      else this.counts.set(item, n - 1);
    };
  }

  values() {
    return Array.from(this.counts.keys());
  }

  get size() {
    return this.counts.size;
  }
}
ts

Dispatch, submit và phần tính lại isDirty/lỗi:

src/lib/pluginComposer.ts
  /** Chạy lần lượt mọi reducer đang đăng ký, rồi tính lại isDirty và lỗi. */
  dispatch = (action: ComposerAction) => {
    let state = this.snapshot.state;
    for (const reducer of this.reducers.values()) state = reducer(state, action);
    this.commit(state);
  };

  /**
   * Plugin vừa mount/unmount: tính lại isDirty/lỗi với tập checker mới. Chỉ báo cho consumer
   * khi kết quả thật sự đổi; nếu luôn báo thì plugin đăng ký lại mỗi render sẽ thành vòng lặp
   * render vô hạn.
   */
  recompute = () => this.commit(this.snapshot.state);

  async submit(): Promise<SubmitResult<S>> {
    const { state, errors } = this.snapshot;
    if (errors.length > 0) return { status: 'invalid', errors };
    // hook chạy NỐI TIẾP: hook sau chỉ được hỏi khi hook trước đã cho qua
    for (const hook of this.hooks.values()) {
      const ok = await hook(state);
      if (!ok) return { status: 'halted' };
    }
    return { status: 'committed', state };
  }

  private commit(state: S) {
    const isDirty = this.checkers.values().some((check) => check(this.beginning, state));
    const errors = this.validators
      .values()
      .map((validate) => validate(state))
      .filter((e): e is string => e != null);
    const prev = this.snapshot;
    // giữ nguyên tham chiếu nếu không có gì đổi: consumer không phải render lại
    if (prev.state === state && prev.isDirty === isDirty && sameStrings(prev.errors, errors))
      return;
    this.snapshot = {
      state,
      isDirty,
      errors: sameStrings(prev.errors, errors) ? prev.errors : errors,
    };
    this.listeners.forEach((l) => l());
  }
ts

Hook cho plugin, đăng ký một wrapper ổn định:

src/lib/pluginComposer.ts
/**
 * Plugin tự đăng ký các phần của mình.
 *
 * Khác bản gốc: bản gốc đăng ký CHÍNH hàm plugin truyền vào, với deps [hàm]. Plugin truyền
 * hàm inline thì mỗi lần render bị gỡ ra rồi đăng ký lại ở CUỐI Map, thứ tự reducer đổi
 * theo thứ tự render. Ở đây mỗi plugin đăng ký một wrapper ỔN ĐỊNH (tạo một lần), wrapper đọc
 * bản mới nhất qua ref, nên thứ tự chỉ phụ thuộc thứ tự mount.
 */
export function usePlugin<S>(parts: PluginParts<S>) {
  const { store } = useComposer<S>();
  const latest = useRef(parts);
  useLayoutEffect(() => {
    latest.current = parts;
  });

  const has = {
    reducer: !!parts.reducer,
    isAltered: !!parts.isAltered,
    validate: !!parts.validate,
    beforeSubmit: !!parts.beforeSubmit,
  };

  useEffect(() => {
    const releases: Array<() => void> = [];
    if (has.reducer)
      releases.push(store.reducers.add((s, a) => latest.current.reducer?.(s, a) ?? s));
    if (has.isAltered)
      releases.push(store.checkers.add((b, c) => latest.current.isAltered?.(b, c) ?? false));
    if (has.validate)
      releases.push(store.validators.add((s) => latest.current.validate?.(s) ?? null));
    if (has.beforeSubmit)
      releases.push(
        store.hooks.add((s) => latest.current.beforeSubmit?.(s) ?? Promise.resolve(true)),
      );
    store.recompute();
    return () => {
      releases.forEach((release) => release());
      store.recompute();
    };
  }, [store, has.reducer, has.isAltered, has.validate, has.beforeSubmit]);
}
ts
Xem toàn bộ src/lib/pluginComposer.ts
src/lib/pluginComposer.ts
import {
  createContext,
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react';

/**
 * Khung composer dạng plugin (ý tưởng từ CometComposer.react + reduceComposerViewState
 * + useComposerViewStateReducer + useContextRef của Facebook).
 *
 * Composer chỉ giữ MỘT state. Mỗi plugin tự đăng ký:
 * - reducer: xử lý action của mình trên state chung,
 * - dirty checker: "phần của tôi đã bị sửa chưa?",
 * - validator: lỗi chặn đăng,
 * - pre-submit hook: được hỏi trước khi đăng (vd mở hộp thoại xác nhận).
 * Không plugin nào biết plugin khác. isDirty = OR của mọi checker.
 */

export interface ComposerAction {
  type: string;
  [key: string]: unknown;
}

export type ComposerReducer<S> = (state: S, action: ComposerAction) => S;
export type DirtyChecker<S> = (beginning: S, current: S) => boolean;
/** trả chuỗi lỗi hoặc null */
export type Validator<S> = (state: S) => string | null;
/** true = cho đăng tiếp, false = dừng */
export type PreSubmitHook<S> = (state: S) => Promise<boolean>;

export type SubmitResult<S> =
  | { status: 'invalid'; errors: string[] }
  | { status: 'halted' }
  | { status: 'committed'; state: S };

/**
 * Tập có đếm tham chiếu: cùng một hàm được đăng ký 2 lần (2 component dùng chung) thì
 * chỉ chạy 1 lần, và chỉ bị gỡ khi lần đăng ký cuối cùng được gỡ. Thứ tự = thứ tự đăng ký đầu.
 */
export class RefCountedRegistry<F> {
  private counts = new Map<F, number>();

  add(item: F): () => void {
    this.counts.set(item, (this.counts.get(item) ?? 0) + 1);
    let released = false;
    return () => {
      if (released) return;
      released = true;
      const n = this.counts.get(item);
      if (n === undefined) return;
      if (n <= 1) this.counts.delete(item);
      else this.counts.set(item, n - 1);
    };
  }

  values() {
    return Array.from(this.counts.keys());
  }

  get size() {
    return this.counts.size;
  }
}

export interface ComposerSnapshot<S> {
  state: S;
  isDirty: boolean;
  errors: string[];
}

export class ComposerStore<S> {
  readonly reducers = new RefCountedRegistry<ComposerReducer<S>>();
  readonly checkers = new RefCountedRegistry<DirtyChecker<S>>();
  readonly validators = new RefCountedRegistry<Validator<S>>();
  readonly hooks = new RefCountedRegistry<PreSubmitHook<S>>();
  private listeners = new Set<() => void>();
  private snapshot: ComposerSnapshot<S>;

  constructor(private readonly beginning: S) {
    this.snapshot = { state: beginning, isDirty: false, errors: [] };
  }

  getSnapshot = () => this.snapshot;

  subscribe = (listener: () => void) => {
    this.listeners.add(listener);
    return () => void this.listeners.delete(listener);
  };

  /** Chạy lần lượt mọi reducer đang đăng ký, rồi tính lại isDirty và lỗi. */
  dispatch = (action: ComposerAction) => {
    let state = this.snapshot.state;
    for (const reducer of this.reducers.values()) state = reducer(state, action);
    this.commit(state);
  };

  /**
   * Plugin vừa mount/unmount: tính lại isDirty/lỗi với tập checker mới. Chỉ báo cho consumer
   * khi kết quả thật sự đổi; nếu luôn báo thì plugin đăng ký lại mỗi render sẽ thành vòng lặp
   * render vô hạn.
   */
  recompute = () => this.commit(this.snapshot.state);

  async submit(): Promise<SubmitResult<S>> {
    const { state, errors } = this.snapshot;
    if (errors.length > 0) return { status: 'invalid', errors };
    // hook chạy NỐI TIẾP: hook sau chỉ được hỏi khi hook trước đã cho qua
    for (const hook of this.hooks.values()) {
      const ok = await hook(state);
      if (!ok) return { status: 'halted' };
    }
    return { status: 'committed', state };
  }

  private commit(state: S) {
    const isDirty = this.checkers.values().some((check) => check(this.beginning, state));
    const errors = this.validators
      .values()
      .map((validate) => validate(state))
      .filter((e): e is string => e != null);
    const prev = this.snapshot;
    // giữ nguyên tham chiếu nếu không có gì đổi: consumer không phải render lại
    if (prev.state === state && prev.isDirty === isDirty && sameStrings(prev.errors, errors))
      return;
    this.snapshot = {
      state,
      isDirty,
      errors: sameStrings(prev.errors, errors) ? prev.errors : errors,
    };
    this.listeners.forEach((l) => l());
  }
}

function sameStrings(a: string[], b: string[]) {
  return a.length === b.length && a.every((x, i) => x === b[i]);
}

// ---------------------------------------------------------------------------
// React
// ---------------------------------------------------------------------------

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const ComposerContext = createContext<ComposerStore<any> | null>(null);

export function useComposerStore<S>(beginning: S) {
  const [store] = useState(() => new ComposerStore(beginning));
  return store;
}

export function useComposer<S>() {
  const store = useContext(ComposerContext) as ComposerStore<S> | null;
  if (!store) throw new Error('useComposer phải nằm trong <ComposerContext.Provider>');
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
  return { ...snapshot, dispatch: store.dispatch, store };
}

export interface PluginParts<S> {
  reducer?: ComposerReducer<S>;
  isAltered?: DirtyChecker<S>;
  validate?: Validator<S>;
  beforeSubmit?: PreSubmitHook<S>;
}

/**
 * Plugin tự đăng ký các phần của mình.
 *
 * Khác bản gốc: bản gốc đăng ký CHÍNH hàm plugin truyền vào, với deps [hàm]. Plugin truyền
 * hàm inline thì mỗi lần render bị gỡ ra rồi đăng ký lại ở CUỐI Map, thứ tự reducer đổi
 * theo thứ tự render. Ở đây mỗi plugin đăng ký một wrapper ỔN ĐỊNH (tạo một lần), wrapper đọc
 * bản mới nhất qua ref, nên thứ tự chỉ phụ thuộc thứ tự mount.
 */
export function usePlugin<S>(parts: PluginParts<S>) {
  const { store } = useComposer<S>();
  const latest = useRef(parts);
  useLayoutEffect(() => {
    latest.current = parts;
  });

  const has = {
    reducer: !!parts.reducer,
    isAltered: !!parts.isAltered,
    validate: !!parts.validate,
    beforeSubmit: !!parts.beforeSubmit,
  };

  useEffect(() => {
    const releases: Array<() => void> = [];
    if (has.reducer)
      releases.push(store.reducers.add((s, a) => latest.current.reducer?.(s, a) ?? s));
    if (has.isAltered)
      releases.push(store.checkers.add((b, c) => latest.current.isAltered?.(b, c) ?? false));
    if (has.validate)
      releases.push(store.validators.add((s) => latest.current.validate?.(s) ?? null));
    if (has.beforeSubmit)
      releases.push(
        store.hooks.add((s) => latest.current.beforeSubmit?.(s) ?? Promise.resolve(true)),
      );
    store.recompute();
    return () => {
      releases.forEach((release) => release());
      store.recompute();
    };
  }, [store, has.reducer, has.isAltered, has.validate, has.beforeSubmit]);
}

/** Hỏi trước khi rời trang khi còn thay đổi chưa đăng. */
export function useWarnBeforeUnload(active: boolean) {
  useEffect(() => {
    if (!active) return;
    const onBeforeUnload = (event: BeforeUnloadEvent) => {
      event.preventDefault();
      event.returnValue = ''; // trình duyệt cũ cần gán returnValue
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [active]);
}
ts

Khác bản gốc:

  • State nằm ngoài React (useSyncExternalStore) thay vì useReducer. Reducer thuần, không gán ref hay gọi callback bên trong, nên StrictMode không làm side effect chạy hai lần.
  • Wrapper ổn định. Mỗi plugin đăng ký một wrapper tạo một lần trong effect; wrapper đọc bản mới nhất của reducer qua ref. Thứ tự reducer chỉ phụ thuộc thứ tự mount. Đổi lại, hai component dùng chung một hàm sẽ có hai wrapper khác nhau, nên đếm tham chiếu không gộp được chúng. Muốn gộp thì gọi thẳng store.reducers.add(hàmỔnĐịnh) với hàm khai báo ở cấp module.
  • Pre-submit hook trả Promise<boolean> thay cho cặp onContinue/onHalt. Hai cách tương đương, nhưng async function đọc dễ hơn.
  • isDirty được tính lại khi plugin mount hoặc unmount. Bản gốc chỉ tính trong reducer, nên gỡ plugin nền màu thì isDirty vẫn giữ giá trị cũ tới lần dispatch sau.

#Đi qua code

  • RefCountedRegistry.add() trả về hàm release. Gọi release hai lần không trừ hai lần (cờ released), tránh lỗi khi cleanup của React chạy lặp.
  • dispatch() chạy các reducer theo thứ tự của values(), tức thứ tự chèn lần đầu của Map. Hàm đã có trong Map mà được thêm lần nữa thì chỉ tăng số đếm, không đổi vị trí.
  • commit() so state, isDirty và danh sách lỗi với snapshot cũ. Không có gì đổi thì không báo listener, nên action không plugin nào xử lý không gây render.
  • recompute() chỉ báo khi kết quả thật sự khác. Lúc viết demo, phiên bản đầu luôn báo, và chế độ "đăng ký kiểu bản gốc" lập tức thành vòng lặp render vô hạn: đăng ký lại → báo → render → hàm inline mới → đăng ký lại. Một ví dụ cụ thể cho thấy đăng ký trong effect mà lại kích hoạt render là rất dễ thành vòng lặp.
  • submit() dùng for…of với await, nên hook chạy tuần tự đúng như chuỗi .then của bản gốc.
  • usePlugin() chỉ đưa vào deps các cờ "có reducer không", "có checker không"… Plugin đổi nội dung hàm thì không cần đăng ký lại; plugin thêm hoặc bớt hẳn một phần thì mới đăng ký lại.

#Tại sao không làm cách khác?

Một reducer lớn ở composer, plugin chỉ gửi action? Composer phải biết mọi loại action và mọi trường state. Gỡ plugin "check-in" thì phải sửa composer. Với hàng chục nhóm cùng làm một composer, file trung tâm sẽ thành nơi xung đột merge liên tục.

Mỗi plugin tự giữ state riêng bằng useState? Khi đó không còn một "view state" để gửi lên server lúc đăng, và plugin không phản ứng được với nhau (nền màu phải tắt khi chữ quá dài). Composer cũng không biết isDirty nếu không hỏi từng plugin.

Dùng Redux với combineReducers? combineReducers chia state thành từng nhánh, mỗi reducer chỉ thấy nhánh của mình. Composer của Facebook cho mọi reducer thấy toàn bộ state, chính là thứ cho phép plugin nền màu đọc độ dài chữ. Ngoài ra, ghép reducer động (thêm khi mount, gỡ khi unmount) không phải cách dùng mặc định của Redux.

#Edge case và cạm bẫy

  • Thứ tự reducer là thứ tự mount. Plugin A phụ thuộc kết quả của plugin B (đọc trường B vừa sửa) thì phải mount sau B. Phụ thuộc kiểu này nên tránh, hoặc ghi rõ trong code.
  • Action trong lúc plugin chưa mount. Đăng ký nằm trong useEffect, nên action được dispatch trong lần render đầu tiên đi qua một tập reducer còn thiếu.
  • Unmount khi hook đang chờ. Plugin ảnh đang hỏi "Đăng không ảnh?" mà bị gỡ: Promise của hook vẫn treo. Hook nên tự huỷ hộp thoại và trả false khi unmount.
  • beforeunload không chạy trong mọi trường hợp. Trình duyệt di động có thể đóng tab mà không phát sự kiện này, và trình duyệt chỉ hiện câu hỏi khi người dùng đã tương tác với trang. Nó là lớp bảo vệ cuối, không thay được việc tự lưu nháp.
  • Validator chạy sau mỗi thay đổi. Validator tốn kém (kiểm link, gọi mạng) không nên đặt ở đây; dùng pre-submit hook thay vào.

#Dùng trong dự án

Form nhiều phần do nhiều người làm: trình soạn email (đính kèm, chữ ký, lịch gửi), form tạo sản phẩm (ảnh, biến thể, giá), editor bài viết.

function AttachmentPlugin() {
  const { state, dispatch } = useComposer<Mail>();
  usePlugin<Mail>({
    reducer: (s, a) => (a.type === 'attach' ? { ...s, files: [...s.files, a.file as File] } : s),
    isAltered: (begin, cur) => begin.files.length !== cur.files.length,
    beforeSubmit: async (s) =>
      /đính kèm|attached/i.test(s.body) && s.files.length === 0 ? confirm('Bạn nhắc tới file đính kèm mà chưa gắn file?') : true,
  });
  return <FilePicker onPick={(file) => dispatch({ type: 'attach', file })} />;
}
tsx

Thêm một tính năng là thêm một component plugin; gỡ tính năng là xoá component đó, không phải sửa composer.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { ComposerStore, RefCountedRegistry } from './pluginComposer';

type S = { text: string; bg: string | null };

describe('RefCountedRegistry', () => {
  it('cùng hàm đăng ký 2 lần chỉ chạy 1 lần, gỡ hết mới mất', () => {
    const reg = new RefCountedRegistry<() => void>();
    const f = () => {};
    const r1 = reg.add(f);
    const r2 = reg.add(f);
    expect(reg.size).toBe(1);
    r1();
    r1(); // gọi lại không đếm hai lần
    expect(reg.size).toBe(1);
    r2();
    expect(reg.size).toBe(0);
  });
});

describe('ComposerStore', () => {
  const make = () => {
    const store = new ComposerStore<S>({ text: '', bg: null });
    store.reducers.add((s, a) => (a.type === 'text' ? { ...s, text: String(a.value) } : s));
    store.reducers.add((s, a) => (a.type === 'bg' ? { ...s, bg: a.value as string } : s));
    return store;
  };

  it('isDirty là OR của các checker đang đăng ký', () => {
    const store = make();
    const releaseBg = store.checkers.add((b, c) => b.bg !== c.bg);
    store.dispatch({ type: 'text', value: 'chào' });
    expect(store.getSnapshot().isDirty).toBe(false); // chưa có checker cho text
    store.dispatch({ type: 'bg', value: 'red' });
    expect(store.getSnapshot().isDirty).toBe(true);
    releaseBg();
    store.recompute();
    expect(store.getSnapshot().isDirty).toBe(false);
  });

  it('validator chặn submit, hook dừng được việc đăng', async () => {
    const store = make();
    store.validators.add((s) => (s.text ? null : 'trống'));
    store.recompute();
    expect(await store.submit()).toEqual({ status: 'invalid', errors: ['trống'] });

    store.dispatch({ type: 'text', value: 'x' });
    const asked: string[] = [];
    store.hooks.add(async () => (asked.push('h1'), true));
    store.hooks.add(async () => (asked.push('h2'), false));
    store.hooks.add(async () => (asked.push('h3'), true));
    expect(await store.submit()).toEqual({ status: 'halted' });
    expect(asked).toEqual(['h1', 'h2']); // h3 không được hỏi
  });
});
ts

#So với thư viện và chuẩn web

  • Lexical (editor mã nguồn mở của Meta) dùng ý tưởng gần giống: plugin là component React, tự registerCommand và registerUpdateListener với editor rồi trả hàm huỷ trong effect. Editor không có danh sách plugin.
  • Redux hỗ trợ thêm reducer lúc chạy qua store.replaceReducer (Redux Toolkit có combineSlices với .inject), nhưng mỗi slice vẫn chỉ thấy nhánh của mình.
  • useSyncExternalStore có từ React 18; đây là cách được khuyến nghị để đọc store bên ngoài React mà không bị "tearing" khi render đồng thời.
  • beforeunload: các trình duyệt hiện đại chỉ hiện thông báo chung, không hiện chuỗi tuỳ biến, và chỉ hiện khi trang đã có tương tác của người dùng (tính tới 10/2026).

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz