Start HereOrdered learning track

Thinking in State Topology

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

State topology sebagai peta keputusan untuk menentukan lokasi, pemilik, lifecycle, dan mekanisme propagasi state di React application yang kompleks.

17 min read3232 words
PrevNext
Lesson 03123 lesson track01–23 Start Here
#react#state-management#state-topology#frontend-architecture+2 more

Part 003 — Thinking in State Topology

React engineer yang kuat tidak memulai desain UI dengan pertanyaan:

Library state management apa yang harus dipakai?

Pertanyaan yang lebih tepat:

State ini hidup di mana, dimiliki siapa, berubah karena event apa, dibaca oleh siapa, harus bertahan berapa lama, dan apa konsekuensinya jika stale, hilang, atau duplikat?

Itulah state topology.

Topology bukan sekadar daftar jenis state. Topology adalah peta hubungan:

  • tempat state disimpan,
  • siapa pemilik otoritatifnya,
  • siapa yang boleh mengubahnya,
  • siapa yang membaca turunan nilainya,
  • bagaimana state berpindah antar boundary,
  • kapan state harus reset,
  • kapan state harus dipertahankan,
  • kapan state harus disinkronkan dengan sistem eksternal.

Banyak bug React tidak berasal dari useState yang salah. Bug muncul karena state ditempatkan di lokasi yang salah.

Contoh:

  • filter tabel disimpan sebagai local state, padahal harus shareable melalui URL,
  • data API disalin ke Redux, lalu cache server menjadi tidak konsisten,
  • selected row disimpan di child component, padahal toolbar global perlu tahu,
  • modal visibility disimpan di lima tempat berbeda,
  • form draft dicampur dengan server entity sehingga optimistic update sulit di-rollback,
  • derived value disimpan sebagai state sehingga mudah stale,
  • workflow step hanya boolean flags sehingga impossible state muncul.

Part ini membangun mental model untuk menentukan topology state sebelum kita bicara library.


1. State Topology dalam Satu Kalimat

State topology adalah jawaban terhadap lima pertanyaan:

1. Where does this state live?
2. Who owns it?
3. Who reads it?
4. Who can change it?
5. What lifecycle should it follow?

Dalam React, jawaban yang berbeda menghasilkan desain yang berbeda:

PertanyaanJawaban BurukJawaban Lebih Baik
Di mana state hidup?"Taruh di global biar gampang.""Letakkan sedekat mungkin dengan pemilik perubahan."
Siapa owner?"Semua component bisa update.""Ada satu source of truth; yang lain membaca atau mengirim intent."
Siapa reader?"Nanti kalau perlu tinggal import store.""Reader menentukan scope propagasi dan biaya re-render."
Siapa writer?"Setter dilempar ke mana-mana.""Writer melalui event/command yang jelas."
Lifecycle?"Selama app jalan.""Reset saat identity berubah, persist saat user expectation membutuhkan."

Topology adalah desain aliran otoritas. Tanpa itu, state management berubah menjadi tempat pembuangan variable.


2. Peta Besar Jenis State

Jangan mulai dari local vs global. Itu terlalu kasar.

Gunakan taxonomy yang lebih tajam:

Setiap jenis punya hukum sendiri.

Jenis StateOwner OtoritatifContohTool Umum
Local UI stateComponent/subtreeinput draft, dropdown openuseState, useReducer
Derived stateTidak disimpan; dihitungtotal, filtered list, validation summaryrender calculation, selector, useMemo bila perlu
Shared client stateUI/application clientselected tab global, local cart draftlifted state, context, reducer, external store
URL stateBrowser locationsearch query, filters, page numberrouter/search params
Server stateBackend/APIuser profile, invoice list, permissions responseTanStack Query, RTK Query, framework loader
External store stateStore di luar Reactglobal store, websocket snapshotuseSyncExternalStore, Zustand, Redux
Persistent client stateBrowser storage/devicetheme, local draft, onboarding dismissedlocalStorage, IndexedDB, storage abstraction
Workflow stateState machine/processwizard, approval, import job UIreducer, XState, custom machine
Ephemeral runtime stateRuntime, bukan UI declarativetimeout id, DOM node, previous valueuseRef

Kesalahan umum: satu library dipakai untuk semua jenis state.

Redux/Zustand bukan pengganti URL. TanStack Query bukan pengganti local form draft. Context bukan query cache. useEffect bukan derived state engine. useRef bukan tempat menyembunyikan state UI.


3. State Lokal: Letakkan Dekat dengan Perubahan

Prinsip pertama:

State should live as close as possible to the component that owns its changes.

Contoh yang sehat:

function CollapsibleSection({ title, children }: PropsWithChildren<{ title: string }>) {
  const [open, setOpen] = useState(false);

  return (
    <section>
      <button onClick={() => setOpen(value => !value)}>{title}</button>
      {open ? <div>{children}</div> : null}
    </section>
  );
}

open adalah local UI state karena:

  • hanya section itu yang membaca,
  • hanya section itu yang mengubah,
  • tidak perlu survive navigation,
  • tidak perlu shareable melalui URL,
  • tidak perlu diketahui parent,
  • kehilangan state saat component unmount dapat diterima.

Jangan angkat state hanya karena "mungkin nanti dipakai".

Anti-pattern: premature lifting

function Page() {
  const [isProfileOpen, setProfileOpen] = useState(false);
  const [isBillingOpen, setBillingOpen] = useState(false);
  const [isAuditOpen, setAuditOpen] = useState(false);

  return (
    <>
      <Section open={isProfileOpen} onOpenChange={setProfileOpen} />
      <Section open={isBillingOpen} onOpenChange={setBillingOpen} />
      <Section open={isAuditOpen} onOpenChange={setAuditOpen} />
    </>
  );
}

Jika parent tidak punya keputusan bisnis terhadap state tersebut, parent hanya menjadi gudang setter.

Versi lebih baik:

function Page() {
  return (
    <>
      <Section />
      <Section />
      <Section />
    </>
  );
}

Local state menjaga boundary tetap kecil.


4. Kapan State Harus Diangkat?

State diangkat jika ada invariant lintas component.

Contoh: dua panel tidak boleh terbuka bersamaan.

function Accordion() {
  const [openId, setOpenId] = useState<string | null>(null);

  return items.map(item => (
    <AccordionItem
      key={item.id}
      item={item}
      open={openId === item.id}
      onOpen={() => setOpenId(item.id)}
      onClose={() => setOpenId(null)}
    />
  ));
}

Di sini openId bukan sekadar data. Ia merepresentasikan invariant:

At most one item can be open.

Jika setiap child menyimpan open sendiri, invariant ini sulit dijaga.

State harus diangkat jika:

  • dua atau lebih component harus berubah bersama,
  • parent perlu mengambil keputusan berdasarkan state child,
  • ada invariant lintas child,
  • state mempengaruhi layout/toolbar/action di luar child,
  • state perlu di-reset saat domain identity parent berubah,
  • state perlu dikendalikan dari luar sebagai controlled component.

Namun lifting bukan berarti global. Lifting hanya sampai closest common owner.


5. Closest Common Owner

React docs sering menyebut "closest common parent" untuk sharing state. Dalam aplikasi kompleks, istilah yang lebih kuat adalah closest common owner.

Parent belum tentu owner. Parent bisa hanya layout shell.

Contoh:

CaseDetailPage
├── CaseHeader
├── CaseTabNavigation
├── CaseActionPanel
└── CaseTimeline

State selectedCaseAction dibaca oleh CaseActionPanel dan CaseTimeline, tetapi diubah oleh CaseHeader. Owner yang wajar mungkin bukan CaseHeader, melainkan CaseDetailPage atau custom hook useCaseActionSelection pada boundary page.

function CaseDetailPage({ caseId }: { caseId: string }) {
  const actionSelection = useCaseActionSelection(caseId);

  return (
    <CaseActionSelectionProvider value={actionSelection}>
      <CaseHeader />
      <CaseActionPanel />
      <CaseTimeline />
    </CaseActionSelectionProvider>
  );
}

Owner bukan berarti component yang pertama kali menerima event. Owner adalah boundary yang:

  • memahami invariant,
  • tahu kapan reset,
  • tahu siapa reader/writer yang sah,
  • bisa mengekspresikan state sebagai domain/application concept.

6. Derived State: Hitung, Jangan Simpan

Derived state adalah nilai yang bisa dihitung dari props/state lain.

Buruk:

function Cart({ items }: { items: CartItem[] }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price * item.quantity, 0));
  }, [items]);

  return <div>Total: {total}</div>;
}

Masalah:

  • render pertama memakai total lama,
  • ada render tambahan setelah effect,
  • ada dua source of truth,
  • dependency effect harus selalu benar,
  • bug muncul jika items dimutasi diam-diam.

Lebih baik:

function Cart({ items }: { items: CartItem[] }) {
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return <div>Total: {total}</div>;
}

Jika mahal, gunakan memoization sebagai optimisasi, bukan sebagai model ownership:

const total = useMemo(() => {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}, [items]);

Checklist derived state:

PertanyaanJika Ya
Bisa dihitung dari props/state/context saat render?Jangan simpan sebagai state.
Butuh cache karena mahal?Pertimbangkan useMemo, selector memoization, atau precomputation.
Butuh berbeda dari sumbernya karena user sedang mengedit?Itu bukan derived; itu draft state.
Butuh snapshot historis?Itu event/history state, bukan derived biasa.

7. Draft State vs Committed State

Banyak bug form berasal dari mencampur dua hal:

Committed state = data yang sudah dianggap benar/tersimpan.
Draft state     = data sementara yang sedang diedit user.

Contoh buruk:

function UserEditor({ user }: { user: User }) {
  const [name, setName] = useState(user.name);

  useEffect(() => {
    setName(user.name);
  }, [user.name]);

  // ...
}

Kode ini terlihat wajar, tapi behavior-nya kabur:

  • jika user.name berubah dari server saat user mengetik, apakah draft harus overwrite?
  • jika user.id berubah, apakah draft harus reset?
  • jika parent re-render dengan object baru tapi user sama, apakah draft reset?

Model lebih jelas:

function UserEditorBoundary({ userId }: { userId: string }) {
  const userQuery = useUserQuery(userId);

  if (!userQuery.data) return <Spinner />;

  return <UserEditor key={userId} initialUser={userQuery.data} />;
}

function UserEditor({ initialUser }: { initialUser: User }) {
  const [draft, setDraft] = useState(() => toUserDraft(initialUser));

  // draft belongs to this mounted editor instance
}

key={userId} menyatakan topology:

Draft is preserved while editing the same user.
Draft resets when user identity changes.

Jangan pakai effect untuk menebak lifecycle kalau identity tree bisa mengekspresikannya lebih jelas.


8. URL State: State yang Harus Bisa Dibagikan

URL adalah state container. Bukan semua state cocok masuk URL, tetapi state tertentu hampir selalu lebih baik di URL:

  • search query,
  • filter,
  • sort,
  • pagination,
  • active tab yang merepresentasikan resource view,
  • selected item jika selection adalah bagian dari navigasi,
  • date range laporan,
  • mode tampilan yang harus shareable/bookmarkable.

Contoh buruk:

function AuditLogPage() {
  const [query, setQuery] = useState('');
  const [page, setPage] = useState(1);
  const [severity, setSeverity] = useState<Severity | null>(null);

  const logs = useAuditLogs({ query, page, severity });
}

Masalah:

  • refresh hilang,
  • tidak bisa share link,
  • back/forward browser tidak sesuai ekspektasi,
  • state tersembunyi dari observability/navigation,
  • deep link tidak mungkin.

Lebih baik:

function AuditLogPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  const filters = parseAuditLogFilters(searchParams);

  const logs = useAuditLogs(filters);

  function updateFilters(next: Partial<AuditLogFilters>) {
    setSearchParams(serializeAuditLogFilters({ ...filters, ...next }));
  }

  return <AuditLogView filters={filters} onFiltersChange={updateFilters} logs={logs} />;
}

URL state punya trade-off:

KeuntunganBiaya
ShareablePerlu parsing/serialization
Back/forward nativeHarus hati-hati replace vs push
Refresh safeTidak cocok untuk data sensitif
DebuggableURL bisa menjadi noisy
Deep-linkableHarus punya default yang stabil

Rule:

If the user expects copy-paste URL to preserve the view, it is probably URL state.

9. Server State: Jangan Disamakan dengan Client State

Server state adalah state yang owner otoritatifnya bukan React app.

Contoh:

  • invoice list,
  • user profile,
  • permission matrix dari backend,
  • case timeline,
  • notification count,
  • feature configuration dari API,
  • status background job.

Ciri server state:

  • bisa stale,
  • perlu fetch/refetch,
  • bisa gagal,
  • bisa dipakai banyak screen,
  • punya cache lifecycle,
  • punya invalidation strategy,
  • bisa diubah oleh actor lain di luar browser saat ini.

Jangan copy server state ke client store tanpa alasan kuat.

Buruk:

const user = await api.getUser(userId);
dispatch(usersSlice.actions.upsertUser(user));

Lalu component membaca dari Redux sebagai source of truth. Sekarang kita harus menjawab:

  • kapan data stale?
  • siapa yang refetch?
  • mutation invalidates slice mana?
  • bagaimana dedup request?
  • bagaimana loading/error state?
  • bagaimana optimistic update rollback?

Kalau jawaban itu tidak jelas, gunakan server-state tool seperti query cache.

function UserPanel({ userId }: { userId: string }) {
  const userQuery = useUserQuery(userId);

  if (userQuery.isPending) return <Spinner />;
  if (userQuery.isError) return <ErrorPanel error={userQuery.error} />;

  return <UserView user={userQuery.data} />;
}

Client state dan server state boleh berinteraksi, tetapi jangan dicampur tanpa boundary.


10. Shared Client State: Jangan Otomatis Global

Shared client state adalah state milik aplikasi client yang dibaca oleh beberapa tempat.

Contoh:

  • current workspace selected di shell,
  • active modal registry,
  • unsaved changes map,
  • client-side cart draft sebelum checkout,
  • selected rows lintas toolbar/table,
  • compare basket,
  • command palette state,
  • layout preference sementara.

Pertanyaan topology:

Apakah state ini shared karena memang domain-nya shared,
atau shared karena component boundary saat ini buruk?

Kadang solusi bukan store. Solusi bisa extraction boundary.

Buruk:

// Any component can update this global state.
const useSelectionStore = create(...);

Lebih baik jika selection hanya relevan dalam satu page:

function InvoiceListPage() {
  const selection = useInvoiceSelection();

  return (
    <InvoiceSelectionProvider value={selection}>
      <InvoiceToolbar />
      <InvoiceTable />
      <InvoiceBulkActionPanel />
    </InvoiceSelectionProvider>
  );
}

Global store dipilih jika:

  • reader/writer melintasi banyak subtree yang tidak punya common owner praktis,
  • state harus bertahan saat route subtree berubah,
  • state adalah capability/application service,
  • observability/time travel/middleware dibutuhkan,
  • state adalah domain client yang memang global.

Context provider di page cukup jika:

  • scope-nya satu page/feature,
  • lifecycle mengikuti mount/unmount boundary,
  • writer jelas,
  • tidak butuh subscription granular yang sangat tinggi.

11. External Store State

External store berarti state hidup di luar React. React hanya subscribe.

Contoh external store:

  • Redux store,
  • Zustand store,
  • custom observable store,
  • websocket state container,
  • browser storage wrapper,
  • collaborative document model,
  • data grid internal model.

Topology external store harus menjawab:

  • apakah store global atau scoped per feature?
  • apakah store instance dibuat per page, per tab, atau singleton module?
  • apakah component membaca seluruh store atau selector kecil?
  • apakah update menghasilkan immutable snapshot?
  • bagaimana unsubscribe saat unmount?
  • apakah SSR/hydration perlu snapshot berbeda?

Contoh scoped store lebih sehat untuk banyak kasus:

function createInvoiceWorkspaceStore(initial: InvoiceWorkspaceState) {
  return createStore<InvoiceWorkspaceState>()((set, get) => ({
    ...initial,
    selectInvoice(invoiceId) {
      set({ selectedInvoiceId: invoiceId });
    },
    clearSelection() {
      set({ selectedInvoiceId: null });
    },
  }));
}

Jangan semua store menjadi singleton module. Singleton membuat lifecycle tersembunyi.


12. Persistent Client State

Persistent client state bertahan melewati reload/session.

Contoh:

  • theme,
  • sidebar collapsed,
  • dismissed onboarding,
  • local draft,
  • last selected workspace,
  • offline queue,
  • feature preference.

Bahaya persistent state:

  • schema berubah tapi storage lama masih ada,
  • data sensitif tersimpan tanpa sadar,
  • cross-tab conflict,
  • hydration mismatch,
  • stale preference mengalahkan server config,
  • user logout tetapi storage tidak dibersihkan.

Minimal abstraction:

type PersistedValue<T> = {
  version: number;
  value: T;
};

function readPersisted<T>(key: string, version: number, fallback: T): T {
  const raw = window.localStorage.getItem(key);
  if (!raw) return fallback;

  try {
    const parsed = JSON.parse(raw) as PersistedValue<T>;
    if (parsed.version !== version) return fallback;
    return parsed.value;
  } catch {
    return fallback;
  }
}

Persistent state selalu butuh:

  • version,
  • migration/clear strategy,
  • privacy review,
  • reset on logout jika user-scoped,
  • conflict semantics jika multi-tab.

13. Workflow State

Workflow state berbeda dari boolean UI state.

Contoh buruk:

const [isLoading, setLoading] = useState(false);
const [isSuccess, setSuccess] = useState(false);
const [isError, setError] = useState(false);
const [isConfirmOpen, setConfirmOpen] = useState(false);

State ini mengizinkan impossible state:

isLoading=true, isSuccess=true, isError=true

Lebih baik:

type SubmitState =
  | { status: 'idle' }
  | { status: 'confirming' }
  | { status: 'submitting' }
  | { status: 'succeeded'; receiptId: string }
  | { status: 'failed'; error: Error };

Workflow state punya transitions:

Workflow state harus dipikir sebagai proses, bukan sekumpulan flag.

Gunakan reducer/state machine jika:

  • ada multi-step flow,
  • async transition,
  • guard,
  • retry/rollback,
  • dependent action,
  • illegal transition perlu dicegah,
  • state harus diuji sebagai transition table.

14. Ephemeral Runtime State

Tidak semua nilai harus menyebabkan render.

Gunakan useRef untuk nilai mutable yang:

  • harus bertahan antar render,
  • tidak perlu memicu render saat berubah,
  • merepresentasikan handle/runtime detail,
  • bukan source of truth UI declarative.

Contoh:

function AutoSaveEditor({ draft }: { draft: Draft }) {
  const timeoutRef = useRef<number | null>(null);

  function scheduleSave(nextDraft: Draft) {
    if (timeoutRef.current !== null) {
      window.clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = window.setTimeout(() => {
      saveDraft(nextDraft);
    }, 500);
  }

  // ...
}

timeoutRef bukan UI state. Jika nilainya berubah, user tidak perlu melihat render baru.

Anti-pattern:

const selectedIdRef = useRef<string | null>(null);

function select(id: string) {
  selectedIdRef.current = id;
}

return <div>Selected: {selectedIdRef.current}</div>;

Ini salah topology. UI membaca selectedIdRef.current, tapi perubahan ref tidak memicu render. Gunakan state/store.


15. State Identity dan Reset Policy

Topology juga menentukan kapan state reset.

Pertanyaan reset policy:

State ini harus bertahan ketika parent re-render?
State ini harus bertahan ketika tab berubah?
State ini harus bertahan ketika entity id berubah?
State ini harus bertahan ketika route berubah?
State ini harus bertahan ketika browser refresh?
State ini harus bertahan ketika user logout?

Contoh:

StateReset SaatPreserve Saat
input draft edit useruserId berubahparent re-render
filter audit loguser clear filter / URL berubahrefresh/share URL
selected row tabledataset identity berubahsort local berubah, jika row id masih ada
modal opennavigation keluar featureparent re-render
server query cacheinvalidation/gccomponent unmount sementara
onboarding dismissedexplicit reset/version migrationreload/session

Reset yang tidak eksplisit menjadi bug tersembunyi.

Gunakan identity React jika reset mengikuti component identity:

<UserEditor key={userId} user={user} />

Gunakan reducer event jika reset adalah domain transition:

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

Gunakan storage/version jika reset adalah persistence lifecycle:

clearUserScopedStorage(userId);

16. Reader/Writer Graph

Untuk state kompleks, gambar graph reader/writer.

Graph ini membantu melihat:

  • state mana yang source of truth,
  • update mana yang command,
  • data mana yang derived,
  • component mana yang seharusnya tidak import store langsung,
  • loop mana yang mungkin terjadi,
  • boundary mana yang terlalu banyak tanggung jawab.

Jika graph memiliki siklus yang tidak disengaja, bug akan muncul.

Contoh siklus buruk:

Effect reads URL -> sets local state -> effect reads local state -> writes URL

Lebih baik: URL menjadi source of truth, local UI hanya draft sementara jika diperlukan.


17. Decision Matrix: Letakkan State di Mana?

Gunakan matrix ini sebelum memilih tool.

KondisiLokasi State yang Disarankan
Hanya satu component yang membaca dan menulislocal state
Beberapa child harus konsisten bersamaclosest common owner
Banyak subtree dalam satu feature membaca statefeature provider/scoped store
State harus shareable/bookmarkableURL
State berasal dari API dan bisa stalequery cache/server-state layer
State adalah long-lived client domainexternal store
State adalah proses dengan transitionreducer/state machine
State hanya handle mutableref
State harus survive reloadpersistent storage with versioning
State bisa dihitung dari state lainderived calculation, bukan stored state

Decision tree:


18. Example: Search Page Topology

Scenario:

  • search input,
  • filters,
  • sort,
  • paginated results,
  • selected rows,
  • bulk archive action,
  • detail drawer,
  • optimistic mutation.

Naive topology:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState({});
  const [sort, setSort] = useState('createdAt:desc');
  const [page, setPage] = useState(1);
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [selectedIds, setSelectedIds] = useState([]);
  const [drawerItem, setDrawerItem] = useState(null);

  useEffect(() => {
    setLoading(true);
    api.search({ query, filters, sort, page }).then(data => {
      setResults(data.items);
      setLoading(false);
    });
  }, [query, filters, sort, page]);
}

Semua terlihat sederhana sampai muncul requirement:

  • user ingin share link,
  • request lama menimpa request baru,
  • selection harus clear saat filter berubah,
  • drawer harus deep-linkable,
  • bulk archive harus optimistic,
  • pagination harus reset saat query berubah,
  • browser back harus mengembalikan filter.

Topology yang lebih matang:

ConcernTopology
query/filter/sort/pageURL state
results/loading/error/cacheserver-state query cache
selectedIdspage-scoped client state
drawer itemURL param atau page state tergantung deep-link requirement
bulk mutationmutation layer + optimistic overlay
pagination resetURL update rule saat query/filter berubah
selection resetreducer event ketika result identity/filter berubah

Sketch:

function SearchPage() {
  const searchState = useSearchUrlState();
  const resultsQuery = useSearchResultsQuery(searchState.queryKey);
  const selection = useSearchSelection({
    resultIds: resultsQuery.data?.items.map(item => item.id) ?? [],
    scopeKey: searchState.selectionScopeKey,
  });

  const archiveMutation = useArchiveResultsMutation({
    onSuccess() {
      selection.clear();
    },
  });

  return (
    <SearchSelectionProvider value={selection}>
      <SearchView
        searchState={searchState}
        resultsQuery={resultsQuery}
        onArchiveSelected={() => archiveMutation.mutate(selection.selectedIds)}
      />
    </SearchSelectionProvider>
  );
}

Perhatikan: tidak ada satu "global state" yang menyelesaikan semua. Ada beberapa state dengan topology berbeda.


19. Example: Approval Workflow UI

Scenario:

  • case bisa berada di draft, submitted, under_review, approved, rejected, escalated,
  • user role menentukan action,
  • action bisa async,
  • beberapa transition butuh confirmation,
  • beberapa action butuh reason,
  • timeline harus update setelah action,
  • UI harus mencegah impossible transition.

Naive topology:

const [isSubmitting, setSubmitting] = useState(false);
const [isApproving, setApproving] = useState(false);
const [isRejecting, setRejecting] = useState(false);
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [error, setError] = useState(null);

Model ini tidak punya grammar.

Topology lebih sehat:

ConcernTopology
authoritative case statusserver state
available actionsderived from server state + permissions
currently executing transitionworkflow state
modal payloadworkflow state
reason draftmodal-local state atau workflow context
timelineserver state/query cache
optimistic statusoptimistic overlay dengan rollback

Sketch:

type CaseActionFlow =
  | { status: 'idle' }
  | { status: 'confirming'; action: CaseAction }
  | { status: 'collectingReason'; action: CaseAction; reason: string }
  | { status: 'executing'; action: CaseAction }
  | { status: 'failed'; action: CaseAction; error: Error };

Topology workflow membuat UI bisa diaudit:

idle -> confirming -> executing -> idle
idle -> collectingReason -> executing -> idle
executing -> failed -> executing
failed -> idle

Di sistem regulatori/enforcement, explicit transition seperti ini sangat penting karena UI bukan hanya tampilan. UI adalah bagian dari lifecycle kontrol.


20. Smell Catalogue: State Topology yang Salah

SmellKemungkinan Masalah
Banyak useEffect hanya untuk copy props ke stateDerived/draft lifecycle tidak jelas
Banyak boolean flags untuk satu flowPerlu union state/state machine
Semua state masuk Redux/ZustandOwnership terlalu global
Context provider berisi object raksasaRerender propagation dan implicit coupling
URL tidak berubah saat filter/search berubahView state tidak shareable
Data API disalin ke local stateServer/client state tercampur
Setter dilempar melewati banyak layerBoundary ownership kabur
useRef dipakai untuk data yang tampil di UIRender reactivity dihindari secara salah
Modal state tersebar di banyak componentPerlu overlay orchestration boundary
Component child tahu terlalu banyak tentang parentComposition contract bocor

21. Heuristics yang Dipakai Engineer Senior

Heuristic 1 — Prefer local until invariant crosses boundary

State lokal adalah default. Angkat hanya saat ada alasan nyata.

Heuristic 2 — Server data belongs to server-state layer

Jika backend adalah owner, UI sebaiknya tidak berpura-pura menjadi database otoritatif.

Heuristic 3 — URL is part of product behavior

Filter/search/sort bukan hanya UI detail. Itu navigasi dan shareability.

Heuristic 4 — Derived state is a liability unless proven otherwise

Menyimpan derived state berarti membuat cache manual. Cache manual butuh invalidation.

Heuristic 5 — Booleans do not model workflows well

Jika ada lebih dari dua status, gunakan discriminated union atau state machine.

Heuristic 6 — Scope store by lifecycle, not convenience

Global singleton mudah dibuat, sulit dibersihkan.

Heuristic 7 — State topology first, library second

Library hanya implementasi dari topology. Library tidak memperbaiki topology yang salah.


22. Refactoring Playbook

Saat menemukan state spaghetti, jangan langsung rewrite. Lakukan langkah ini:

Step 1 — Inventory state

Tulis semua state:

name
current location
readers
writers
source of truth
reset condition
persistence requirement
failure impact

Step 2 — Klasifikasikan jenisnya

local / derived / shared client / URL / server / external / persistent / workflow / ref

Step 3 — Temukan duplicate source of truth

Cari pasangan seperti:

props.user.name + local name
query params + local filters
server data + Redux copy
boolean flags + status field

Step 4 — Tentukan owner baru

Jangan hanya pindahkan state. Pindahkan ownership.

Step 5 — Ubah writer menjadi event/command

Dari:

setSelectedIds(ids)

Menjadi:

selection.toggle(invoiceId)
selection.clearBecauseFilterChanged(nextFilterKey)

Step 6 — Tambahkan tests untuk invariant

Test bukan hanya render. Test transition.


23. Checklist Desain State Topology

Sebelum merge feature React kompleks, tanyakan:

[ ] Apa source of truth untuk setiap state penting?
[ ] Apakah ada derived state yang disimpan tanpa alasan?
[ ] Apakah server state dicampur ke client store?
[ ] Apakah state yang harus shareable sudah berada di URL?
[ ] Apakah local state diangkat terlalu jauh?
[ ] Apakah global store benar-benar perlu global?
[ ] Apakah reset policy eksplisit?
[ ] Apakah workflow memakai union/machine, bukan boolean acak?
[ ] Apakah writer state mengekspresikan intent, bukan setter mentah?
[ ] Apakah topology bisa digambar sebagai reader/writer graph?

Jika tidak bisa menggambar graph-nya, kemungkinan desainnya belum matang.


24. Latihan Implementasi

Exercise 1 — Klasifikasikan State

Ambil page nyata di codebase. Buat tabel:

| State | Current Location | Readers | Writers | Correct Topology | Refactor Needed | |---|---|---|---|---| | query | local state | search page | input | URL state | yes | | data | local state | table | effect | server state | yes | | selectedIds | table | toolbar/table | row checkbox | page-scoped state | yes |

Target: minimal 15 state entries.

Exercise 2 — Hilangkan Derived State

Cari pattern:

useEffect(() => {
  setX(computeY(a, b));
}, [a, b]);

Refactor menjadi render calculation atau selector.

Exercise 3 — Ubah Boolean Flow ke Union State

Dari:

isLoading, isError, isSuccess, isConfirmOpen

Menjadi:

type FlowState =
  | { status: 'idle' }
  | { status: 'confirming' }
  | { status: 'submitting' }
  | { status: 'failed'; error: Error }
  | { status: 'succeeded' };

Exercise 4 — Gambar Reader/Writer Graph

Pilih satu feature dan gambar Mermaid graph. Tandai:

  • source of truth,
  • derived values,
  • commands,
  • effects/synchronization,
  • external systems.

25. Kesimpulan Part 003

State topology adalah fondasi state management React.

Ringkasnya:

State yang berbeda punya owner, lifecycle, dan mekanisme propagasi berbeda.
Jangan pakai satu tool untuk semua state.
Letakkan state sedekat mungkin dengan owner-nya.
Angkat state hanya saat invariant menyeberang boundary.
Derived state sebaiknya dihitung, bukan disimpan.
Server state bukan client state.
URL adalah state container untuk view yang shareable.
Workflow state butuh model transition, bukan boolean flags.
Store harus mengikuti lifecycle, bukan convenience.

Jika Part 001 menjelaskan bagaimana React runtime bekerja, dan Part 002 menjelaskan boundary/ownership, Part 003 memberi peta untuk menentukan tempat state hidup.

Part berikutnya membahas Rendering Is Not Execution: mengapa render harus pure, mengapa side effect tidak boleh berada di render, dan bagaimana membedakan render calculation, event logic, effect synchronization, dan command orchestration.


Referensi

Lesson Recap

You just completed lesson 03 in start here. 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.