Build CoreOrdered learning track

Context Update Patterns

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 034

Context update patterns for production React: setter context, reducer context, split state/action providers, command context, controlled providers, async boundaries, update invariants, performance, tests, and failure modes.

8 min read1460 words
PrevNext
Lesson 34123 lesson track24–67 Build Core
#react#context#hooks#reducer+2 more

Part 034 — Context Update Patterns

Part sebelumnya membahas cara membaca Context dengan aman. Sekarang kita masuk ke sisi yang lebih rawan: mengubah state melalui Context.

Kesalahan umum:

const AppContext = createContext({
  state: {},
  setState: () => {},
});

Lalu semua component boleh membaca dan mengubah semuanya.

Itu bukan architecture. Itu global mutable object yang kebetulan dibungkus React.

Context update pattern yang benar harus menjawab:

Siapa pemilik state?
Siapa boleh mengirim event?
Apakah consumer boleh tahu bentuk storage?
Apakah update harus imperative command atau reducer action?
Apakah update local, shared, server, URL, atau workflow?
Apa invariant yang harus dijaga?
Apa dampak rerender saat value berubah?

1. Mental Model Update Context

Context sendiri tidak “mengelola state”. Context hanya menyebarkan value.

State tetap berasal dari:

  • useState,
  • useReducer,
  • external store,
  • server cache,
  • parent props,
  • route state,
  • framework/router,
  • machine/actor.

Provider biasanya menjadi bridge:

Jika provider value berubah, React memberitahu consumer context tersebut. Maka desain update Context selalu berhubungan dengan desain rerender.


2. Pattern 1 — State + Setter Context

Pattern paling sederhana:

type SidebarContextValue = {
  isOpen: boolean;
  setOpen(next: boolean): void;
};

const SidebarContext = createContext<SidebarContextValue | null>(null);

function SidebarProvider({ children }: { children: React.ReactNode }) {
  const [isOpen, setOpen] = useState(false);

  const value = useMemo(
    () => ({ isOpen, setOpen }),
    [isOpen]
  );

  return (
    <SidebarContext.Provider value={value}>
      {children}
    </SidebarContext.Provider>
  );
}

Consumer:

function SidebarToggle() {
  const { isOpen, setOpen } = useSidebar();

  return (
    <button onClick={() => setOpen(!isOpen)}>
      {isOpen ? "Close" : "Open"}
    </button>
  );
}

Cocok untuk:

  • boolean sederhana,
  • UI shell state,
  • low-frequency state,
  • subtree kecil,
  • invariant ringan.

Kelemahan:

  • setter terlalu primitive,
  • consumer bisa membuat transisi ilegal,
  • banyak consumer bisa menulis state secara tidak terkoordinasi,
  • event domain tidak terdokumentasi,
  • stale closure mudah muncul jika memakai setOpen(!isOpen) di banyak tempat.

Lebih aman:

setOpen((current) => !current);

Atau expose command:

type SidebarContextValue = {
  isOpen: boolean;
  open(): void;
  close(): void;
  toggle(): void;
};

3. Pattern 2 — State + Domain Commands

Daripada mengekspos setter mentah, expose command berbasis intent.

type SidebarContextValue = {
  isOpen: boolean;
  openSidebar(): void;
  closeSidebar(): void;
  toggleSidebar(): void;
};

function SidebarProvider({ children }: { children: React.ReactNode }) {
  const [isOpen, setOpen] = useState(false);

  const openSidebar = useCallback(() => setOpen(true), []);
  const closeSidebar = useCallback(() => setOpen(false), []);
  const toggleSidebar = useCallback(() => {
    setOpen((current) => !current);
  }, []);

  const value = useMemo(
    () => ({
      isOpen,
      openSidebar,
      closeSidebar,
      toggleSidebar,
    }),
    [isOpen, openSidebar, closeSidebar, toggleSidebar]
  );

  return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
}

Command lebih baik daripada setter karena:

  • consumer bicara intent,
  • provider menjaga implementasi,
  • transisi bisa divalidasi,
  • refactor ke reducer lebih mudah,
  • logging/analytics bisa ditambahkan di satu tempat.

Contoh command dengan guard:

const closeSidebar = useCallback(() => {
  setOpen((current) => {
    if (!current) return current;
    return false;
  });
}, []);

4. Pattern 3 — Reducer Context

Jika state punya banyak event dan invariant, gunakan reducer.

type CaseSelectionState = {
  selectedIds: string[];
  anchorId: string | null;
};

type CaseSelectionAction =
  | { type: "selected"; id: string }
  | { type: "deselected"; id: string }
  | { type: "cleared" }
  | { type: "rangeSelected"; fromId: string; toId: string; visibleIds: string[] };

function caseSelectionReducer(
  state: CaseSelectionState,
  action: CaseSelectionAction
): CaseSelectionState {
  switch (action.type) {
    case "selected":
      if (state.selectedIds.includes(action.id)) {
        return state;
      }

      return {
        ...state,
        selectedIds: [...state.selectedIds, action.id],
        anchorId: action.id,
      };

    case "deselected":
      return {
        ...state,
        selectedIds: state.selectedIds.filter((id) => id !== action.id),
        anchorId: state.anchorId === action.id ? null : state.anchorId,
      };

    case "cleared":
      return {
        selectedIds: [],
        anchorId: null,
      };

    case "rangeSelected": {
      const fromIndex = action.visibleIds.indexOf(action.fromId);
      const toIndex = action.visibleIds.indexOf(action.toId);

      if (fromIndex === -1 || toIndex === -1) {
        return state;
      }

      const [start, end] =
        fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];

      const ids = action.visibleIds.slice(start, end + 1);
      const selected = new Set([...state.selectedIds, ...ids]);

      return {
        selectedIds: [...selected],
        anchorId: action.toId,
      };
    }

    default:
      return assertNever(action);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

Provider:

type CaseSelectionContextValue = {
  state: CaseSelectionState;
  dispatch: React.Dispatch<CaseSelectionAction>;
};

const CaseSelectionContext =
  createContext<CaseSelectionContextValue | null>(null);

function CaseSelectionProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(caseSelectionReducer, {
    selectedIds: [],
    anchorId: null,
  });

  const value = useMemo(() => ({ state, dispatch }), [state]);

  return (
    <CaseSelectionContext.Provider value={value}>
      {children}
    </CaseSelectionContext.Provider>
  );
}

Cocok untuk:

  • multi-step UI,
  • selection state,
  • wizard state,
  • form interaction state,
  • local workflow,
  • state dengan invariant eksplisit.

5. Problem Reducer Context Naif

Provider di atas memberikan object { state, dispatch }.

Masalahnya: saat state berubah, object value berubah. Consumer yang hanya butuh dispatch juga bisa ikut render.

Contoh:

function SelectAllToolbarButton() {
  const { dispatch } = useCaseSelection();

  return (
    <button onClick={() => dispatch({ type: "cleared" })}>
      Clear
    </button>
  );
}

Component ini tidak membaca state, tapi subscribe ke context yang value-nya berubah setiap state berubah.

Solusinya: split state context dan dispatch context.


6. Pattern 4 — Split State and Dispatch Context

const CaseSelectionStateContext =
  createContext<CaseSelectionState | null>(null);

const CaseSelectionDispatchContext =
  createContext<React.Dispatch<CaseSelectionAction> | null>(null);

export function CaseSelectionProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [state, dispatch] = useReducer(caseSelectionReducer, {
    selectedIds: [],
    anchorId: null,
  });

  return (
    <CaseSelectionDispatchContext.Provider value={dispatch}>
      <CaseSelectionStateContext.Provider value={state}>
        {children}
      </CaseSelectionStateContext.Provider>
    </CaseSelectionDispatchContext.Provider>
  );
}

Hooks:

export function useCaseSelectionState() {
  const state = useContext(CaseSelectionStateContext);

  if (state === null) {
    throw new Error("useCaseSelectionState must be used inside <CaseSelectionProvider />");
  }

  return state;
}

export function useCaseSelectionDispatch() {
  const dispatch = useContext(CaseSelectionDispatchContext);

  if (dispatch === null) {
    throw new Error("useCaseSelectionDispatch must be used inside <CaseSelectionProvider />");
  }

  return dispatch;
}

Benefit:

  • components yang hanya dispatch tidak subscribe ke state changes,
  • action sender lebih murah,
  • read/write dependency lebih eksplisit.

Catatan: dispatch dari useReducer stabil identitasnya. Ini membuat dispatch context sangat cocok untuk dipisah.


7. Pattern 5 — State Context + Command Context

Dispatch action kadang terlalu rendah level untuk UI consumer. Kita bisa expose command.

type CaseSelectionCommands = {
  select(id: string): void;
  deselect(id: string): void;
  clear(): void;
  selectRange(input: {
    fromId: string;
    toId: string;
    visibleIds: string[];
  }): void;
};

const CaseSelectionCommandContext =
  createContext<CaseSelectionCommands | null>(null);

function CaseSelectionProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(caseSelectionReducer, initialState);

  const commands = useMemo<CaseSelectionCommands>(
    () => ({
      select(id) {
        dispatch({ type: "selected", id });
      },
      deselect(id) {
        dispatch({ type: "deselected", id });
      },
      clear() {
        dispatch({ type: "cleared" });
      },
      selectRange(input) {
        dispatch({ type: "rangeSelected", ...input });
      },
    }),
    [dispatch]
  );

  return (
    <CaseSelectionCommandContext.Provider value={commands}>
      <CaseSelectionStateContext.Provider value={state}>
        {children}
      </CaseSelectionStateContext.Provider>
    </CaseSelectionCommandContext.Provider>
  );
}

Consumer:

function ClearSelectionButton() {
  const { clear } = useCaseSelectionCommands();

  return <button onClick={clear}>Clear</button>;
}

Command context cocok saat:

  • event/action name internal berbeda dari UI intent,
  • command perlu analytics/logging,
  • command perlu validasi,
  • command perlu compose beberapa dispatch,
  • command mungkin pindah ke external store nanti.

8. Reducer Action vs Command

Jangan campur dua level ini.

Reducer action adalah event/state transition internal:

{ type: "rangeSelected", fromId, toId, visibleIds }

Command adalah capability yang dipanggil UI:

selection.selectRange({ fromId, toId, visibleIds });

Mapping:

Kenapa dipisah?

  • UI tidak perlu tahu action union,
  • reducer tetap deterministic,
  • command bisa punya dependency,
  • command bisa memutuskan apakah dispatch diperlukan,
  • action tetap serializable dan testable.

9. Pattern 6 — Controlled Provider

Kadang provider tidak boleh menjadi owner state. Ia hanya menyebarkan state dari parent.

type CaseSelectionProviderProps = {
  selectedIds: string[];
  onSelectedIdsChange(next: string[]): void;
  children: React.ReactNode;
};

function CaseSelectionProvider({
  selectedIds,
  onSelectedIdsChange,
  children,
}: CaseSelectionProviderProps) {
  const commands = useMemo(
    () => ({
      select(id: string) {
        if (selectedIds.includes(id)) return;
        onSelectedIdsChange([...selectedIds, id]);
      },
      clear() {
        onSelectedIdsChange([]);
      },
    }),
    [selectedIds, onSelectedIdsChange]
  );

  const state = useMemo(
    () => ({ selectedIds }),
    [selectedIds]
  );

  return (
    <CaseSelectionCommandContext.Provider value={commands}>
      <CaseSelectionStateContext.Provider value={state}>
        {children}
      </CaseSelectionStateContext.Provider>
    </CaseSelectionCommandContext.Provider>
  );
}

Cocok saat:

  • state harus sinkron dengan URL,
  • parent/route owner state,
  • server data menentukan selection,
  • workflow engine owner state,
  • component library primitive butuh controllable provider.

Risiko:

  • parent callback harus stabil,
  • update bisa race jika parent state berasal dari sumber async,
  • provider tidak boleh menyimpan shadow state yang bertentangan.

10. Pattern 7 — Controllable Provider

Provider bisa mendukung controlled dan uncontrolled mode.

type ControllableSelectionProviderProps = {
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  onSelectedIdsChange?(next: string[]): void;
  children: React.ReactNode;
};

function ControllableSelectionProvider({
  selectedIds,
  defaultSelectedIds = [],
  onSelectedIdsChange,
  children,
}: ControllableSelectionProviderProps) {
  const [internalSelectedIds, setInternalSelectedIds] =
    useState(defaultSelectedIds);

  const isControlled = selectedIds !== undefined;
  const currentSelectedIds = isControlled ? selectedIds : internalSelectedIds;

  const setSelectedIds = useCallback(
    (next: string[]) => {
      if (!isControlled) {
        setInternalSelectedIds(next);
      }

      onSelectedIdsChange?.(next);
    },
    [isControlled, onSelectedIdsChange]
  );

  const commands = useMemo(
    () => ({
      select(id: string) {
        if (currentSelectedIds.includes(id)) return;
        setSelectedIds([...currentSelectedIds, id]);
      },
      clear() {
        setSelectedIds([]);
      },
    }),
    [currentSelectedIds, setSelectedIds]
  );

  return (
    <CaseSelectionCommandContext.Provider value={commands}>
      <CaseSelectionStateContext.Provider value={{ selectedIds: currentSelectedIds }}>
        {children}
      </CaseSelectionStateContext.Provider>
    </CaseSelectionCommandContext.Provider>
  );
}

Ini pattern design-system yang kuat, tapi harus punya aturan:

- controlled mode: selectedIds menjadi source of truth
- uncontrolled mode: internal state menjadi source of truth
- jangan switch controlled/uncontrolled selama lifecycle jika tidak sengaja
- onChange adalah notification, bukan source of truth

11. Pattern 8 — Update Context sebagai Capability, State dari Server Cache

Tidak semua update context harus mengubah local state. Kadang update berarti menjalankan command ke server/cache.

type CaseCommands = {
  approveCase(caseId: string): Promise<void>;
  assignCase(caseId: string, assigneeId: string): Promise<void>;
};

const CaseCommandContext = createContext<CaseCommands | null>(null);

Provider:

function CaseCommandProvider({ children }: { children: React.ReactNode }) {
  const queryClient = useQueryClient();
  const api = useApiClient();

  const commands = useMemo<CaseCommands>(
    () => ({
      async approveCase(caseId) {
        await api.approveCase(caseId);
        await queryClient.invalidateQueries({ queryKey: ["case", caseId] });
      },
      async assignCase(caseId, assigneeId) {
        await api.assignCase(caseId, assigneeId);
        await queryClient.invalidateQueries({ queryKey: ["case", caseId] });
      },
    }),
    [api, queryClient]
  );

  return (
    <CaseCommandContext.Provider value={commands}>
      {children}
    </CaseCommandContext.Provider>
  );
}

Consumer tidak tahu:

  • endpoint,
  • cache key,
  • invalidation,
  • optimistic update,
  • retry policy.

Ia hanya tahu command domain.

Ini sering lebih baik daripada menyimpan server data di Context.


12. Async Boundary: Jangan Taruh Async di Reducer

Reducer harus pure.

Bad:

async function reducer(state, action) {
  const result = await api.save(action.payload);
  return { ...state, result };
}

Reducer tidak boleh melakukan async work, mutation, logging side effect, fetch, atau random value.

Correct:

async function saveDraft(input: DraftInput) {
  dispatch({ type: "saveStarted" });

  try {
    const result = await api.saveDraft(input);
    dispatch({ type: "saveSucceeded", result });
  } catch (error) {
    dispatch({ type: "saveFailed", error });
  }
}

Flow:

Command owns async. Reducer owns transition.


13. Update Invariants

Setiap update Context harus punya invariant.

Contoh selection invariant:

- selectedIds tidak boleh duplicate
- anchorId harus null atau salah satu visible/selected id
- clear harus menghapus selectedIds dan anchorId
- range selection harus mengabaikan id yang tidak visible

Reducer membuat invariant eksplisit.

Setter mentah membuat invariant tersebar.

Bad:

setSelectedIds([...selectedIds, id]);

Good:

dispatch({ type: "selected", id });

Better untuk consumer:

selection.select(id);

14. Optimistic Context Update

Kadang Context dipakai untuk optimistic local state sebelum server confirm.

Contoh:

type SaveState =
  | { tag: "idle" }
  | { tag: "saving"; draftId: string }
  | { tag: "saved"; savedAt: Date }
  | { tag: "failed"; error: string; draft: Draft };

type SaveAction =
  | { type: "saveRequested"; draftId: string }
  | { type: "saveSucceeded"; savedAt: Date }
  | { type: "saveFailed"; error: string; draft: Draft };

Command:

async function saveDraft(draft: Draft) {
  dispatch({ type: "saveRequested", draftId: draft.id });

  try {
    await api.saveDraft(draft);
    dispatch({ type: "saveSucceeded", savedAt: new Date() });
  } catch (error) {
    dispatch({
      type: "saveFailed",
      error: toErrorMessage(error),
      draft,
    });
  }
}

Jangan hanya pakai boolean:

const [isSaving, setSaving] = useState(false);
const [error, setError] = useState(null);

Untuk workflow async, boolean terpisah mudah menciptakan impossible states:

isSaving=true + error exists + savedAt exists

Gunakan tagged union/state machine style.


15. Context Update dan Event Handler

Consumer biasanya mengirim update dari event handler.

function ApproveButton({ caseId }: { caseId: string }) {
  const { approveCase } = useCaseCommands();

  return (
    <button onClick={() => approveCase(caseId)}>
      Approve
    </button>
  );
}

Jangan mengirim update saat render.

Bad:

function ApproveButton({ caseId, autoApprove }: Props) {
  const { approveCase } = useCaseCommands();

  if (autoApprove) {
    approveCase(caseId);
  }

  return <button>Approve</button>;
}

Render harus pure. Update harus terjadi di event, effect yang valid, action, atau external callback.

Jika perlu auto-run karena sinkronisasi external condition, evaluasi apakah itu benar-benar effect. Banyak kasus “auto update” sebenarnya sebaiknya menjadi responsibility parent/router/workflow, bukan render child.


16. Context Update dan Function Identity

Provider value sering seperti ini:

const value = {
  state,
  saveDraft,
  approveCase,
  rejectCase,
};

Setiap render membuat object baru. Ini bisa memicu consumer context.

Gunakan useMemo dan useCallback untuk stabilitas identity ketika relevan.

const saveDraft = useCallback(async (draft: Draft) => {
  // ...
}, [api]);

const value = useMemo(
  () => ({
    state,
    saveDraft,
  }),
  [state, saveDraft]
);

Namun jangan overfit. Jika context state berubah setiap keypress dan ratusan consumer subscribe, useMemo tidak menyelesaikan root problem. Root problem adalah provider value terlalu luas atau state terlalu volatile untuk Context.


17. Context Update Performance Ladder

Ketika Context update menyebabkan rerender terlalu luas, lakukan dari yang paling murah:

1. Pastikan provider tidak membuat value object/function baru tanpa perlu.
2. Split state context dan command/dispatch context.
3. Split context berdasarkan volatility dan readership.
4. Pindahkan high-frequency state lebih dekat ke owner.
5. Gunakan external store dengan selector subscription.
6. Gunakan server cache untuk server state, bukan Context.
7. Gunakan state machine/actor untuk workflow yang kompleks.

Jangan langsung lompat ke global store. Banyak masalah selesai dengan ownership dan context splitting.


18. Case Study: Case Workspace Context

Kita bangun mini architecture untuk halaman regulatory case.

18.1 Requirements

- Page menampilkan case detail.
- User bisa select related findings.
- User bisa approve case jika permission dan status valid.
- Selection state local ke page.
- Case data berasal dari server cache, bukan Context.
- Commands approval harus menyembunyikan API/cache detail.

18.2 State Boundary

Context yang dipakai:

PermissionContext       -> read-only/capability
CaseSelectionContext    -> local UI state
CaseCommandContext      -> async domain command
Server cache            -> case data owner

Kita tidak membuat CaseWorkspaceEverythingContext.


18.3 Selection Provider

type SelectionState = {
  selectedFindingIds: string[];
};

type SelectionAction =
  | { type: "findingSelected"; id: string }
  | { type: "findingDeselected"; id: string }
  | { type: "selectionCleared" };

function selectionReducer(
  state: SelectionState,
  action: SelectionAction
): SelectionState {
  switch (action.type) {
    case "findingSelected":
      if (state.selectedFindingIds.includes(action.id)) {
        return state;
      }

      return {
        selectedFindingIds: [...state.selectedFindingIds, action.id],
      };

    case "findingDeselected":
      return {
        selectedFindingIds: state.selectedFindingIds.filter(
          (id) => id !== action.id
        ),
      };

    case "selectionCleared":
      return { selectedFindingIds: [] };

    default:
      return assertNever(action);
  }
}

Provider:

const SelectionStateContext = createContext<SelectionState | null>(null);
const SelectionCommandContext = createContext<SelectionCommands | null>(null);

type SelectionCommands = {
  selectFinding(id: string): void;
  deselectFinding(id: string): void;
  clearSelection(): void;
};

function FindingSelectionProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(selectionReducer, {
    selectedFindingIds: [],
  });

  const commands = useMemo<SelectionCommands>(
    () => ({
      selectFinding(id) {
        dispatch({ type: "findingSelected", id });
      },
      deselectFinding(id) {
        dispatch({ type: "findingDeselected", id });
      },
      clearSelection() {
        dispatch({ type: "selectionCleared" });
      },
    }),
    []
  );

  return (
    <SelectionCommandContext.Provider value={commands}>
      <SelectionStateContext.Provider value={state}>
        {children}
      </SelectionStateContext.Provider>
    </SelectionCommandContext.Provider>
  );
}

Read hooks:

function useFindingSelectionState() {
  const state = useContext(SelectionStateContext);

  if (state === null) {
    throw new Error(
      "useFindingSelectionState must be used inside <FindingSelectionProvider />"
    );
  }

  return state;
}

function useFindingSelectionCommands() {
  const commands = useContext(SelectionCommandContext);

  if (commands === null) {
    throw new Error(
      "useFindingSelectionCommands must be used inside <FindingSelectionProvider />"
    );
  }

  return commands;
}

Domain read hook:

function useIsFindingSelected(id: string) {
  const { selectedFindingIds } = useFindingSelectionState();
  return selectedFindingIds.includes(id);
}

Caveat: this still rerenders when selection state changes. For very large tables, move selection into external store with selector. For moderate page-local state, this is fine.


18.4 Command Provider

type CaseCommands = {
  approveCase(caseId: string): Promise<void>;
};

const CaseCommandContext = createContext<CaseCommands | null>(null);

function CaseCommandProvider({ children }: { children: React.ReactNode }) {
  const api = useApiClient();
  const queryClient = useQueryClient();

  const commands = useMemo<CaseCommands>(
    () => ({
      async approveCase(caseId) {
        await api.approveCase(caseId);
        await queryClient.invalidateQueries({ queryKey: ["case", caseId] });
      },
    }),
    [api, queryClient]
  );

  return (
    <CaseCommandContext.Provider value={commands}>
      {children}
    </CaseCommandContext.Provider>
  );
}

function useCaseCommands() {
  const commands = useContext(CaseCommandContext);

  if (commands === null) {
    throw new Error("useCaseCommands must be used inside <CaseCommandProvider />");
  }

  return commands;
}

Consumer:

function ApproveCaseButton({ caseId, status }: { caseId: string; status: CaseStatus }) {
  const { approveCase } = useCaseCommands();
  const canApprove = useCanApproveCase(status);

  return (
    <button
      disabled={!canApprove}
      onClick={() => approveCase(caseId)}
    >
      Approve
    </button>
  );
}

19. Testing Context Update

19.1 Reducer Unit Test

it("does not duplicate selected ids", () => {
  const state = { selectedFindingIds: ["f-1"] };

  const next = selectionReducer(state, {
    type: "findingSelected",
    id: "f-1",
  });

  expect(next).toEqual(state);
});

19.2 Command Hook Test via Consumer

function TestConsumer() {
  const { selectFinding } = useFindingSelectionCommands();
  const { selectedFindingIds } = useFindingSelectionState();

  return (
    <>
      <button onClick={() => selectFinding("f-1")}>Select</button>
      <output>{selectedFindingIds.join(",")}</output>
    </>
  );
}

it("selects finding through command context", async () => {
  render(
    <FindingSelectionProvider>
      <TestConsumer />
    </FindingSelectionProvider>
  );

  await userEvent.click(screen.getByRole("button", { name: /select/i }));

  expect(screen.getByText("f-1")).toBeInTheDocument();
});

19.3 Async Command Test

it("approves case and invalidates query", async () => {
  const api = {
    approveCase: vi.fn().mockResolvedValue(undefined),
  };

  const queryClient = {
    invalidateQueries: vi.fn().mockResolvedValue(undefined),
  };

  render(
    <ApiClientProvider value={api}>
      <QueryClientProvider value={queryClient}>
        <CaseCommandProvider>
          <ApproveCaseButton caseId="case-1" status="READY_FOR_APPROVAL" />
        </CaseCommandProvider>
      </QueryClientProvider>
    </ApiClientProvider>
  );

  await userEvent.click(screen.getByRole("button", { name: /approve/i }));

  expect(api.approveCase).toHaveBeenCalledWith("case-1");
  expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
    queryKey: ["case", "case-1"],
  });
});

20. Failure Modes

20.1 Global Setter Context

Gejala:

const { setAppState } = useAppContext();

Semua component bisa mengubah semua state.

Dampak:

  • invariant rusak,
  • debugging sulit,
  • event domain hilang,
  • update source tidak terlacak.

Fix:

  • expose command,
  • gunakan reducer,
  • split provider by ownership.

20.2 Context as Server Cache

Gejala:

const CaseContext = createContext({
  case: null,
  setCase: () => {},
});

Lalu fetch, refresh, invalidation, retry, stale state, pagination, optimistic update ditangani manual.

Fix:

  • server state ke query/cache library,
  • Context untuk command/capability atau scoped metadata,
  • jangan duplikasi server cache di Context.

20.3 State and Dispatch in Same Context

Gejala:

const { dispatch } = useContext(StoreContext);

dispatch-only component rerender saat state berubah.

Fix:

  • split state context dan dispatch/command context.

20.4 Reducer Menjadi Tempat Side Effect

Gejala:

case "save":
  localStorage.setItem("draft", JSON.stringify(state));
  return state;

Fix:

  • reducer pure,
  • side effect di command/effect layer,
  • reducer hanya transition.

20.5 Action Union Bocor ke UI

Gejala:

dispatch({
  type: "caseWorkflowTransitionedFromReadyToApprovedBySupervisor",
  ...
});

UI terlalu tahu detail internal workflow.

Fix:

approveCase(caseId)

Command mapping ke action internal.


20.6 Controlled Provider Shadow State

Gejala:

const [internal, setInternal] = useState(props.value);

Lalu internal dan props diverge.

Fix:

  • controlled mode menggunakan prop sebagai source of truth,
  • uncontrolled mode menggunakan internal state,
  • jangan sync prop ke state dengan effect kecuali ada alasan lifecycle yang jelas.

20.7 High-Frequency Context State

Gejala:

  • mouse move,
  • scroll position,
  • text input per keypress,
  • drag state,
  • table selection ribuan row,

semuanya masuk Context besar.

Fix:

  • localize state,
  • use ref for non-render state,
  • external store selector,
  • virtualization-aware state,
  • split high-frequency from low-frequency state.

21. Refactor Recipe: Dari Context Setter ke Reducer + Commands

Sebelum:

type WizardContextValue = {
  step: number;
  setStep(step: number): void;
  data: WizardData;
  setData(data: WizardData): void;
};

Masalah:

setStep(4);
setData({});

Consumer bisa skip validation dan menciptakan state ilegal.

Sesudah:

type WizardState =
  | { tag: "editingProfile"; data: ProfileDraft }
  | { tag: "reviewing"; data: CompleteDraft }
  | { tag: "submitting"; data: CompleteDraft }
  | { tag: "submitted"; receiptId: string }
  | { tag: "failed"; data: CompleteDraft; error: string };

type WizardCommandContextValue = {
  submitProfile(input: ProfileDraft): void;
  confirmReview(): void;
  retry(): void;
  reset(): void;
};

Consumer tidak mengatur step. Consumer mengirim intent.


22. Decision Matrix

SituationUpdate Pattern
Boolean sederhanastate + command
Banyak eventreducer
Banyak consumer dispatch-onlysplit state/dispatch
UI tidak perlu tahu actioncommand context
Parent owner statecontrolled provider
Library componentcontrollable provider
Async commandcommand layer + reducer events
Server mutationcommand context + server cache invalidation
High-frequency granular updateexternal store
Workflow kompleksstate machine/actor

23. Review Checklist

Sebelum menulis update Context:

[ ] Apakah Context ini benar-benar owner state?
[ ] Apakah state ini local/shared/server/URL/workflow?
[ ] Apakah setter mentah membocorkan invariant?
[ ] Apakah command lebih tepat daripada setter?
[ ] Apakah reducer diperlukan?
[ ] Apakah dispatch-only consumer harus terhindar dari rerender?
[ ] Apakah state context dan command context perlu dipisah?
[ ] Apakah async work berada di luar reducer?
[ ] Apakah provider value stabil?
[ ] Apakah state terlalu volatile untuk Context?
[ ] Apakah server state tidak diduplikasi?
[ ] Apakah update path bisa dites?

24. Latihan Implementasi

Bangun CaseReviewProvider:

Requirement:

- State:
  - selectedFindingIds
  - reviewMode: "idle" | "reviewing" | "submitting" | "submitted" | "failed"
  - error message saat gagal
- Commands:
  - selectFinding(id)
  - deselectFinding(id)
  - clearSelection()
  - submitReview(caseId)
  - retrySubmit(caseId)
- submitReview async:
  - dispatch submitStarted
  - call api.submitReview
  - dispatch submitSucceeded atau submitFailed
- Pisahkan StateContext dan CommandContext
- Jangan expose dispatch ke UI component biasa
- Reducer harus pure
- Tambahkan test untuk:
  - duplicate selection tidak terjadi
  - clear reset selection
  - submit success
  - submit failure
  - command hook throw jika di luar provider

Stretch goal:

Refactor submit state menjadi tagged union agar impossible states hilang.

25. Ringkasan

Context update pattern yang baik tidak dimulai dari “butuh global state”. Ia dimulai dari ownership dan invariant.

Setter mentah cocok untuk state kecil.
Command cocok untuk intent.
Reducer cocok untuk banyak transisi dan invariant.
Split context cocok untuk mengurangi rerender.
Controlled provider cocok saat parent adalah owner.
External store cocok saat perlu granular subscription.
Server cache cocok untuk server state.
State machine cocok untuk workflow.

Context tetap sederhana. Yang harus matang adalah boundary di sekitarnya.

Part berikutnya akan membahas Context performance cost: kapan Context rerender menjadi masalah, bagaimana membaca sinyalnya, dan strategi refactor tanpa premature optimization.

Lesson Recap

You just completed lesson 34 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.