Build CoreOrdered learning track

Command Query Separation in UI

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

Command Query Separation in React UI: separating read models from command ports, preserving invariants, structuring mutations, optimistic updates, pending/error lifecycle, and production workflow boundaries.

11 min read2028 words
PrevNext
Lesson 44123 lesson track24–67 Build Core
#react#cqrs#command-query-separation#state-management+2 more

Part 044 — Command Query Separation in UI

Command Query Separation di UI berarti:

Separate what the component reads from what the component asks the system to do.

Ini bukan berarti kita harus membangun full CQRS backend, event sourcing, command bus enterprise, atau distributed architecture. Di React, ini jauh lebih sederhana dan sangat praktis:

  • Query/read side: data yang component butuhkan untuk render.
  • Command/write side: intent atau operasi yang component minta dijalankan.

Contoh buruk:

<CaseHeader case={caseDetail} setCase={setCaseDetail} />

Contoh lebih baik:

<CaseHeader
  caseView={caseView}
  onTitleEditRequested={requestTitleEdit}
  onEscalationRequested={requestEscalation}
/>

Dengan begitu, component tidak diberi akses bebas untuk mengubah seluruh object case. Ia hanya diberi read model dan command port yang diizinkan.


1. Mental Model

Component render dari read model. Component tidak langsung memanipulasi state arbitrary. Ia mengirim command/intent ke boundary yang punya authority.

Rule inti:

Read data should be easy to consume.
Write operations should be hard to misuse.

2. Kenapa Ini Penting di React

Tanpa separation, UI mudah berubah menjadi campuran:

  • rendering logic,
  • validation,
  • mutation,
  • permission check,
  • audit logging,
  • cache invalidation,
  • optimistic update,
  • analytics,
  • navigation,
  • toast,
  • confirmation modal,
  • retry behavior.

Contoh yang sering tumbuh liar:

function ApproveButton({ caseData, setCaseData }: Props) {
  async function handleClick() {
    if (!caseData.permissions.canApprove) return;

    setCaseData({ ...caseData, status: 'approved' });

    try {
      await api.approveCase(caseData.id);
      toast.success('Approved');
      analytics.track('case_approved');
    } catch (error) {
      setCaseData({ ...caseData, status: 'submitted' });
      toast.error('Failed');
    }
  }

  return <button onClick={handleClick}>Approve</button>;
}

Masalah:

  • button tahu permission model,
  • button tahu status transition,
  • button melakukan optimistic update,
  • button tahu API,
  • button tahu toast,
  • button tahu analytics,
  • rollback memakai stale caseData,
  • invariant domain tersebar.

Versi lebih sehat:

<ApproveButton
  disabled={!caseView.actions.approve.enabled}
  reason={caseView.actions.approve.reason}
  pending={commands.approve.pending}
  onApproveRequested={commands.approve.request}
/>

Button hanya tahu:

  • apakah action bisa diklik,
  • apakah pending,
  • apa yang harus dipanggil saat user meminta approve.

Semua policy dan lifecycle ada di orchestration boundary.


3. Query Side: Read Model

Read model adalah bentuk data yang nyaman untuk render, bukan selalu bentuk data mentah dari server.

Server response:

type CaseDto = {
  id: string;
  status: 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED';
  assigneeUserId: string | null;
  slaDueAt: string | null;
  version: number;
  permissions: string[];
};

Read model UI:

type CaseView = {
  id: string;
  statusLabel: string;
  statusTone: 'neutral' | 'warning' | 'success' | 'danger';
  assigneeLabel: string;
  sla: {
    label: string;
    overdue: boolean;
  };
  actions: {
    approve: ActionAvailability;
    reject: ActionAvailability;
    escalate: ActionAvailability;
  };
  version: number;
};

type ActionAvailability = {
  enabled: boolean;
  reason?: string;
};

Component lebih mudah ditulis:

function CaseActionBar({
  caseView,
  commands,
}: {
  caseView: CaseView;
  commands: CaseCommands;
}) {
  return (
    <div>
      <button
        disabled={!caseView.actions.approve.enabled || commands.approve.pending}
        title={caseView.actions.approve.reason}
        onClick={commands.approve.request}
      >
        Approve
      </button>
    </div>
  );
}

UI tidak harus mengulang permission logic di tiap tempat.


4. Command Side: Command Port

Command port adalah API tindakan yang boleh diminta UI.

type CaseCommands = {
  approve: Command<void>;
  reject: Command<{ reason: string }>;
  escalate: Command<{ reason: string; targetQueueId: string }>;
};

type Command<TInput> = {
  pending: boolean;
  error: string | null;
  request: (input: TInput) => void;
};

Atau lebih sederhana:

type CaseCommands = {
  requestApproval: () => void;
  requestRejection: (reason: string) => void;
  requestEscalation: (input: EscalationInput) => void;
};

Kuncinya:

  • command berbentuk intent,
  • command tidak mengekspos setter mentah,
  • command bisa mengatur pending/error,
  • command bisa menjalankan permission guard,
  • command bisa membuka confirmation modal,
  • command bisa menjalankan mutation,
  • command bisa invalidasi cache,
  • command bisa audit.

5. Command Bukan Event DOM

Kurang baik:

<button onClick={(event) => approve(event)} />

Lebih baik:

<button onClick={() => commands.approve.request()} />

DOM event berhenti di leaf. Command port menerima domain input.

Jika command butuh input:

commands.reject.request({ reason });

Bukan:

commands.reject(event);

Command harus bebas dari detail DOM agar bisa dipanggil dari:

  • button,
  • keyboard shortcut,
  • command palette,
  • context menu,
  • bulk action,
  • automation test,
  • retry handler.

6. Read Model vs State Model

Jangan samakan read model dengan state internal.

Read model boleh derived.

Contoh selector:

function toCaseView(caseDto: CaseDto, currentUser: User): CaseView {
  const canApprove =
    caseDto.status === 'SUBMITTED' &&
    caseDto.permissions.includes('case.approve');

  return {
    id: caseDto.id,
    statusLabel: formatStatus(caseDto.status),
    statusTone: statusTone(caseDto.status),
    assigneeLabel: caseDto.assigneeUserId ?? 'Unassigned',
    sla: toSlaView(caseDto.slaDueAt),
    actions: {
      approve: canApprove
        ? { enabled: true }
        : { enabled: false, reason: 'Case is not approvable' },
      reject: {
        enabled:
          caseDto.status === 'SUBMITTED' &&
          caseDto.permissions.includes('case.reject'),
      },
      escalate: {
        enabled: caseDto.permissions.includes('case.escalate'),
      },
    },
    version: caseDto.version,
  };
}

Component tidak perlu tahu expression permission. Ia menerima result.


7. Read Model Harus Stabil Secara Semantik

Jika read model dibuat ulang setiap render, tidak selalu masalah. Tapi jika diteruskan ke memoized subtree, context, atau external registry, identity bisa penting.

const caseView = useMemo(() => {
  return toCaseView(caseDto, currentUser);
}, [caseDto, currentUser]);

Gunakan memoization karena ada kontrak identity/performance, bukan ritual.

Jika read model murah dan tidak melewati memo boundary, plain calculation saat render sering lebih baik.


8. Command Harus Menjaga Invariant

Setter tidak menjaga invariant.

setStatus('approved')

Command bisa menjaga invariant.

requestApproval()

Implementation:

function canApprove(caseDto: CaseDto): boolean {
  return (
    caseDto.status === 'SUBMITTED' &&
    caseDto.permissions.includes('case.approve')
  );
}

async function approveCaseCommand(caseDto: CaseDto) {
  if (!canApprove(caseDto)) {
    throw new Error('Case cannot be approved in current state');
  }

  return api.approveCase({
    caseId: caseDto.id,
    expectedVersion: caseDto.version,
  });
}

UI disabled state adalah hint, bukan security/invariant mechanism. Command tetap harus guard.


9. Local Reducer as Command Boundary

Untuk UI-only workflow, reducer bisa menjadi command boundary.

type WizardState = {
  step: 'profile' | 'documents' | 'review' | 'submitted';
  dirty: boolean;
};

type WizardEvent =
  | { type: 'next.requested' }
  | { type: 'back.requested' }
  | { type: 'field.changed' }
  | { type: 'submit.requested' }
  | { type: 'submit.succeeded' };

function wizardReducer(state: WizardState, event: WizardEvent): WizardState {
  switch (event.type) {
    case 'next.requested':
      if (state.step === 'profile') return { ...state, step: 'documents' };
      if (state.step === 'documents') return { ...state, step: 'review' };
      return state;

    case 'back.requested':
      if (state.step === 'review') return { ...state, step: 'documents' };
      if (state.step === 'documents') return { ...state, step: 'profile' };
      return state;

    case 'field.changed':
      return { ...state, dirty: true };

    case 'submit.requested':
      if (state.step !== 'review') return state;
      return state;

    case 'submit.succeeded':
      return { ...state, step: 'submitted', dirty: false };
  }
}

Command port:

function useWizardCommands() {
  const [state, dispatch] = useReducer(wizardReducer, {
    step: 'profile',
    dirty: false,
  });

  const commands = {
    next: () => dispatch({ type: 'next.requested' }),
    back: () => dispatch({ type: 'back.requested' }),
    markDirty: () => dispatch({ type: 'field.changed' }),
    submit: () => dispatch({ type: 'submit.requested' }),
  };

  return { state, commands };
}

Component tidak diberi setStep. Ia diberi next, back, submit.


10. Server Mutation as Command Boundary

Untuk server state, command biasanya berupa mutation.

function useApproveCaseCommand(caseId: string) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (input: { expectedVersion: number }) => {
      return api.approveCase({ caseId, expectedVersion: input.expectedVersion });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['case', caseId] });
      queryClient.invalidateQueries({ queryKey: ['cases'] });
    },
  });

  return {
    pending: mutation.isPending,
    error: mutation.error,
    request: mutation.mutate,
  };
}

UI:

function ApproveButton({
  availability,
  command,
  version,
}: {
  availability: ActionAvailability;
  command: ReturnType<typeof useApproveCaseCommand>;
  version: number;
}) {
  return (
    <button
      disabled={!availability.enabled || command.pending}
      title={availability.reason}
      onClick={() => command.request({ expectedVersion: version })}
    >
      Approve
    </button>
  );
}

Separation:

  • component reads availability, pending, version,
  • component sends command input,
  • command handles API + invalidation.

11. React 19 Form Actions as Command Boundary

React 19 memperkuat pola form action: form dapat menerima action function, dan hooks seperti useActionState / useFormStatus membantu pending/error state.

Secara arsitektural, ini cocok dengan command separation:

function RenameCaseForm({ actionState, formAction }: Props) {
  return (
    <form action={formAction}>
      <input name="title" />
      {actionState.error ? <p>{actionState.error}</p> : null}
      <SubmitButton />
    </form>
  );
}

Submit button membaca form status:

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

Modelnya tetap sama:

Form renders read model.
Form submits command.
Action returns next action state.
UI renders the result.

Jangan membuat form child memanggil arbitrary setter parent jika form command punya lifecycle, validation, dan server interaction.


12. Optimistic Command

Optimistic update adalah command yang memperbarui UI sebelum server memberi hasil final.

Command contract harus memuat:

  • optimistic state apa yang diubah,
  • rollback snapshot,
  • conflict handling,
  • invalidation setelah success,
  • duplicate command prevention,
  • user feedback jika gagal.

Contoh simplified:

function useOptimisticApproveCase(caseId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.approveCase,
    onMutate: async (input: { expectedVersion: number }) => {
      await queryClient.cancelQueries({ queryKey: ['case', caseId] });

      const previous = queryClient.getQueryData<CaseDto>(['case', caseId]);

      queryClient.setQueryData<CaseDto>(['case', caseId], (current) => {
        if (!current) return current;
        return { ...current, status: 'APPROVED' };
      });

      return { previous };
    },
    onError: (_error, _input, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['case', caseId], context.previous);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['case', caseId] });
    },
  });
}

UI tidak perlu tahu rollback mechanics.


13. Query Key and Command Boundary

Dalam server-state cache, read side biasanya query key.

const caseQuery = useQuery({
  queryKey: ['case', caseId],
  queryFn: () => api.getCase(caseId),
});

Command side harus tahu query mana yang perlu di-invalidasi.

const approveMutation = useMutation({
  mutationFn: api.approveCase,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['case', caseId] });
    queryClient.invalidateQueries({ queryKey: ['case-history', caseId] });
    queryClient.invalidateQueries({ queryKey: ['cases', 'submitted'] });
  },
});

Jangan sembarangan invalidasi semua query jika boundary bisa lebih spesifik. Tapi jangan juga terlalu granular sampai UI stale.

Command design harus menjawab:

After this command succeeds, which read models are no longer trustworthy?

14. Command Result Model

Command bisa punya result berbeda.

14.1 Void Command

type VoidCommand = {
  request: () => void;
  pending: boolean;
};

Cocok jika hasilnya hanya invalidasi/refetch.

14.2 Data-Returning Command

type RenameCommand = {
  request: (input: RenameInput) => Promise<RenameResult>;
  pending: boolean;
};

Cocok jika caller perlu result langsung, misalnya created id.

14.3 State-Producing Command

type FormCommand = {
  actionState: ActionState;
  formAction: (formData: FormData) => void;
};

Cocok untuk form actions.

14.4 Workflow Command

type WorkflowCommand<TInput> = {
  state: 'idle' | 'confirming' | 'pending' | 'succeeded' | 'failed';
  request: (input: TInput) => void;
  confirm: () => void;
  cancel: () => void;
  retry: () => void;
};

Cocok untuk command dengan confirmation dan retry.


15. Do Not Over-Engineer Small Components

Command Query Separation bukan alasan membuat abstraction berlebihan.

Untuk local input:

<input value={name} onChange={(event) => setName(event.target.value)} />

Ini cukup.

Jangan ubah semua hal menjadi:

const nameReadModel = useNameReadModel();
const nameCommandBus = useNameCommandBus();

Gunakan separation saat ada kompleksitas nyata:

  • shared state,
  • domain invariant,
  • async mutation,
  • permission,
  • audit,
  • cache invalidation,
  • confirmation,
  • optimistic update,
  • multi-component orchestration,
  • workflow step transitions,
  • repeated pattern across features.

Prinsipnya:

Simple state can use setters locally.
Boundary state should expose commands.

16. Page Orchestration Hook Pattern

Pattern yang kuat:

function useCaseDetailPage(caseId: string) {
  const caseQuery = useCaseQuery(caseId);
  const approveCommand = useApproveCaseCommand(caseId);
  const rejectCommand = useRejectCaseCommand(caseId);

  const caseView = useMemo(() => {
    if (!caseQuery.data) return null;
    return toCaseView(caseQuery.data);
  }, [caseQuery.data]);

  return {
    read: {
      loading: caseQuery.isLoading,
      error: caseQuery.error,
      caseView,
    },
    commands: {
      approve: approveCommand,
      reject: rejectCommand,
    },
  };
}

Page:

function CaseDetailPage({ caseId }: { caseId: string }) {
  const model = useCaseDetailPage(caseId);

  if (model.read.loading) return <CaseSkeleton />;
  if (model.read.error) return <ErrorState />;
  if (!model.read.caseView) return <NotFoundState />;

  return (
    <CaseDetailScreen
      caseView={model.read.caseView}
      commands={model.commands}
    />
  );
}

Screen:

function CaseDetailScreen({
  caseView,
  commands,
}: {
  caseView: CaseView;
  commands: CaseCommands;
}) {
  return (
    <>
      <CaseHeader caseView={caseView} />
      <CaseActionBar caseView={caseView} commands={commands} />
      <CaseTimeline caseId={caseView.id} />
    </>
  );
}

Keuntungan:

  • page hook mengatur query/mutation orchestration,
  • screen component mostly presentational,
  • action bar tidak tahu API,
  • case header tidak tahu commands,
  • tests bisa fokus per boundary.

17. Command Context Pattern

Jika banyak component dalam subtree butuh command yang sama, command bisa lewat context.

type CaseCommandContextValue = {
  approve: Command<void>;
  reject: Command<{ reason: string }>;
  escalate: Command<EscalationInput>;
};

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

function useCaseCommands() {
  const value = useContext(CaseCommandContext);
  if (!value) throw new Error('useCaseCommands must be used within CaseCommandProvider');
  return value;
}

Provider:

function CaseCommandProvider({ caseId, children }: PropsWithChildren<{ caseId: string }>) {
  const commands = useMemo(
    () => ({
      approve: createApproveCommand(caseId),
      reject: createRejectCommand(caseId),
      escalate: createEscalateCommand(caseId),
    }),
    [caseId],
  );

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

Namun hati-hati: command object yang unstable dapat menyebabkan rerender luas. Jika command state berubah high-frequency, external store atau colocated command state mungkin lebih tepat.


18. Command Bus: Kapan Boleh?

Command bus di UI bisa berguna untuk cross-tree commands:

  • global command palette,
  • keyboard shortcuts,
  • modal service,
  • toast service,
  • layout shell actions.

Contoh capability-style command bus:

type AppCommand =
  | { type: 'modal.open'; modal: ModalDescriptor }
  | { type: 'toast.show'; message: ToastMessage }
  | { type: 'shortcut.register'; shortcut: ShortcutDescriptor };

type CommandBus = {
  dispatch: (command: AppCommand) => void;
};

Tapi command bus untuk domain state sering berbahaya.

Anti-pattern:

commandBus.dispatch({ type: 'case.approve', caseId });

Jika ini membuat domain mutation tersembunyi dari feature boundary, debugging akan sulit.

Gunakan command bus untuk infrastructure/capability, bukan sebagai jalan pintas semua domain action.


19. Permission as Query, Enforcement as Command Guard

UI sering butuh menampilkan action availability.

Read side:

type ActionAvailability = {
  visible: boolean;
  enabled: boolean;
  reason?: string;
};

Command side:

async function approveCase(input: ApproveInput) {
  const permission = await permissionService.check('case.approve', input.caseId);

  if (!permission.allowed) {
    throw new ForbiddenCommandError(permission.reason);
  }

  return api.approveCase(input);
}

Pemisahan penting:

  • read side memberi UX yang jelas,
  • command side tetap enforce.

Jangan hanya menyembunyikan button dan menganggap system aman.


20. Confirmation as Command Orchestration

Delete action biasanya punya confirmation.

Jangan letakkan semua di button reusable.

function DeleteCaseButton({ onDeleteRequested, disabled }: Props) {
  return (
    <button disabled={disabled} onClick={onDeleteRequested}>
      Delete
    </button>
  );
}

Orchestration:

function useDeleteCaseWorkflow(caseId: string) {
  const confirm = useConfirmDialog();
  const mutation = useDeleteCaseMutation(caseId);

  async function request() {
    const accepted = await confirm({
      title: 'Delete case?',
      body: 'This action cannot be undone.',
      confirmLabel: 'Delete',
    });

    if (!accepted) return;

    mutation.mutate();
  }

  return {
    pending: mutation.isPending,
    error: mutation.error,
    request,
  };
}

Button tetap kecil. Workflow boundary memegang confirmation + mutation.


21. Navigation as Command or Effect?

Navigation setelah action sukses bisa menjadi bagian command lifecycle.

const mutation = useMutation({
  mutationFn: api.createCase,
  onSuccess: (createdCase) => {
    navigate(`/cases/${createdCase.id}`);
  },
});

Atau orchestration callback:

async function createAndNavigate(input: CreateCaseInput) {
  const created = await createCase(input);
  navigate(`/cases/${created.id}`);
}

Jangan memakai effect yang mengamati createdCase jika navigation adalah akibat langsung dari user command dan bisa dijalankan di command handler.

Kurang baik:

useEffect(() => {
  if (createdCase) {
    navigate(`/cases/${createdCase.id}`);
  }
}, [createdCase, navigate]);

Ini mengubah event-specific logic menjadi reactive effect. Effect sebaiknya untuk sinkronisasi external system, bukan default tempat response command.


22. Read Side Tidak Boleh Menjalankan Command

Selector/read model harus pure.

Buruk:

function toCaseView(caseDto: CaseDto) {
  if (caseDto.status === 'EXPIRED') {
    api.markExpired(caseDto.id);
  }

  return { ... };
}

Read mapping tidak boleh punya side effect.

Read side harus bisa dipanggil berkali-kali tanpa mengubah sistem.

Benar:

function toCaseView(caseDto: CaseDto): CaseView {
  return { ... };
}

Command terpisah:

function requestMarkExpired(caseId: string) {
  return api.markExpired(caseId);
}

Ini sejalan dengan purity React: render dan calculation yang dipakai render harus idempotent.


23. Command Tidak Harus Selalu Async

Command bisa lokal:

const commands = {
  openFilterPanel: () => setFilterPanelOpen(true),
  closeFilterPanel: () => setFilterPanelOpen(false),
  resetFilters: () => dispatch({ type: 'filters.reset' }),
};

Bahkan untuk local state, command API bisa berguna jika component boundary besar.

Tapi jangan over-abstraction untuk state trivial di component kecil.


24. Example: Complex Search Page

State:

  • query text,
  • filters,
  • sort,
  • page,
  • selected row ids,
  • server results,
  • export command,
  • saved search command.

Tanpa separation, search page cepat kacau.

Read model:

type SearchPageReadModel = {
  query: string;
  filters: FilterView[];
  sort: SortView;
  results: ResultRowView[];
  pagination: PaginationView;
  selectedCount: number;
  loading: boolean;
  error: string | null;
  actions: {
    export: ActionAvailability;
    saveSearch: ActionAvailability;
    bulkAssign: ActionAvailability;
  };
};

Command port:

type SearchPageCommands = {
  setQuery: (query: string) => void;
  setFilter: (filterId: string, value: unknown) => void;
  clearFilters: () => void;
  setSort: (sort: SortInput) => void;
  goToPage: (page: number) => void;
  selectRow: (rowId: string) => void;
  clearSelection: () => void;
  exportResults: () => void;
  saveSearch: () => void;
};

Catatan: setQuery di sini masih command port karena boundary-nya page-level. Namun untuk domain action, gunakan nama intent seperti exportResults, bukan setExporting(true).


25. Example: Regulatory Case Workflow

Read side:

type EnforcementCaseView = {
  id: string;
  status: 'Intake' | 'Assessment' | 'Investigation' | 'Enforcement' | 'Closed';
  riskLevel: 'Low' | 'Medium' | 'High' | 'Critical';
  assignedUnit: string;
  nextActions: {
    acceptIntake: ActionAvailability;
    requestMoreInfo: ActionAvailability;
    escalateToInvestigation: ActionAvailability;
    closeNoAction: ActionAvailability;
  };
  auditSummary: string;
  version: number;
};

Command side:

type EnforcementCaseCommands = {
  acceptIntake: Command<{ expectedVersion: number }>;
  requestMoreInfo: Command<{ message: string; expectedVersion: number }>;
  escalateToInvestigation: Command<{ reason: string; expectedVersion: number }>;
  closeNoAction: Command<{ reason: string; expectedVersion: number }>;
};

Kenapa ini kuat?

  • UI membaca possible actions,
  • command tetap membawa expected version,
  • audit reason eksplisit,
  • illegal transition bisa dicegah,
  • permission bisa muncul di read model tapi tetap enforce di command.

26. Testing Strategy

26.1 Test Read Model Mapping

it('disables approve when case is draft', () => {
  const view = toCaseView({
    ...caseFixture,
    status: 'DRAFT',
    permissions: ['case.approve'],
  });

  expect(view.actions.approve.enabled).toBe(false);
});

26.2 Test Command Guard

it('does not call API when case cannot be approved', async () => {
  const api = createMockApi();
  const command = createApproveCaseCommand({
    api,
    caseDto: { ...caseFixture, status: 'DRAFT' },
  });

  await expect(command.request()).rejects.toThrow('cannot be approved');
  expect(api.approveCase).not.toHaveBeenCalled();
});

26.3 Test Component Wiring

it('calls approve command when approve button is clicked', async () => {
  const request = vi.fn();

  render(
    <ApproveButton
      availability={{ enabled: true }}
      pending={false}
      onApproveRequested={request}
    />,
  );

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

  expect(request).toHaveBeenCalledTimes(1);
});

Component test tidak perlu tahu API, cache invalidation, atau reducer internals.


27. Failure Modes

Failure modeGejalaPenyebabPerbaikan
Setter as commandchild mengubah state arbitrarilysetter bocor melewati boundarycommand/intent callback
Read side mutationrender memicu side effectselector tidak purepisahkan command
Button owns domainleaf component tahu API/policyorchestration salah tempatpage/domain hook command
UI-only permissionuser action diblok di button sajacommand tidak guardenforce di command/backend
Cache staleUI tidak berubah setelah mutationinvalidation tidak jelascommand owns invalidation map
Over-invalidatingsemua data refetchcommand terlalu kasarquery key boundary
Duplicate submitcommand reentrantpending/idempotency tidak adapending guard + idempotency key
Command hidden in effectaction jalan karena state berubahevent logic ditaruh di effectjalankan di command handler
Read model too rawcomponent berisi mapping policyDTO langsung ke UIselector/read model layer
Read model too magicalmapping menyembunyikan domainselector terlalu besarpisah policy/domain selector
Command bus abusesulit trace mutationsemua action globalfeature-scoped command ports
Async lifecycle scatteredpending/error banyak versicommand tidak punya state modelcommand object dengan pending/error
Optimistic rollback brokenUI inconsistentrollback snapshot hilangcommand owns rollback context

28. Refactoring Recipe

Ketika menemukan component seperti ini:

<Widget data={data} setData={setData} api={api} toast={toast} />

Refactor bertahap:

Step 1 — Pisahkan read props dan callbacks

<Widget
  view={toWidgetView(data)}
  onPrimaryActionRequested={handlePrimaryAction}
/>

Step 2 — Pindahkan mutation ke command hook

const primaryCommand = usePrimaryWidgetCommand(widgetId);

<Widget
  view={widgetView}
  primaryCommand={primaryCommand}
/>

Step 3 — Jadikan read model explicit

type WidgetView = {
  title: string;
  description: string;
  actions: {
    primary: ActionAvailability;
  };
};

Step 4 — Tambahkan command lifecycle

type WidgetCommand = {
  pending: boolean;
  error: string | null;
  request: () => void;
};

Step 5 — Test boundary

  • test selector,
  • test command,
  • test component wiring.

29. Decision Matrix

KondisiRead sideCommand side
Local input sederhanalocal statelocal setter
Reusable primitivecontrolled value propsonValueChange
Complex formform read modelsubmit command/action
Server dataquery result/view modelmutation command
Workflowstate machine snapshottransition event/command
Permissioned actionaction availabilityguarded command
Optimistic UIoptimistic read model/cachecommand with rollback
Cross-tree infracapability contextcommand bus/capability
Domain mutationdomain view modelfeature-scoped command

30. Review Checklist

[ ] Apakah component menerima read model, bukan raw mutable state yang terlalu besar?
[ ] Apakah component mengirim intent/command, bukan memanggil setter arbitrary?
[ ] Apakah read model pure dan idempotent?
[ ] Apakah command menjaga invariant, bukan hanya button disabled?
[ ] Apakah command punya pending/error/retry semantics yang jelas?
[ ] Apakah command tahu query/cache mana yang harus invalidated?
[ ] Apakah optimistic update punya rollback path?
[ ] Apakah navigation/toast/audit berada di orchestration boundary yang tepat?
[ ] Apakah permission muncul sebagai UX hint dan juga command/backend enforcement?
[ ] Apakah command bus tidak dipakai sebagai global dumping ground?
[ ] Apakah tests memisahkan read mapping, command behavior, dan component wiring?

31. Latihan

Latihan 1 — Refactor Detail Page

Ambil component yang menerima:

<DetailPanel entity={entity} setEntity={setEntity} />

Refactor menjadi:

<DetailPanel view={entityView} commands={entityCommands} />

Tentukan:

  • field mana masuk read model,
  • command apa saja yang boleh dilakukan,
  • pending/error state setiap command,
  • invalidation setelah command sukses.

Latihan 2 — Build Command Hook

Buat useApproveInvoiceCommand(invoiceId) yang:

  • cek expected version,
  • disable double submit,
  • optimistic update status,
  • rollback jika gagal,
  • invalidasi invoice detail dan invoice list,
  • expose { pending, error, request }.

Latihan 3 — Separate Form Draft and Submit Command

Untuk form edit profile:

  • draft state lokal,
  • validation read model,
  • submit command async,
  • server error mapping,
  • success navigation.

Jangan taruh navigation di effect yang mengamati success jika bisa langsung menjadi bagian command lifecycle.


32. Ringkasan

Command Query Separation di React adalah alat untuk menjaga UI tetap bisa dipahami saat kompleksitas naik.

Tanpa separation:

component reads everything, mutates everything, and knows everything

Dengan separation:

component reads a view model
component sends an intent
orchestration boundary owns command lifecycle
state/cache/server produces new read model

Prinsip akhir:

Queries shape the screen.
Commands change the world.
Do not let every component change the world directly.

Jika read side dan command side jelas, component menjadi lebih kecil, invariant lebih kuat, testing lebih fokus, dan perubahan arsitektur state management tidak memaksa rewrite seluruh UI.


References

Lesson Recap

You just completed lesson 44 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.