Final StretchOrdered learning track

Storybook as State Lab

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

Menggunakan Storybook sebagai state lab untuk memodelkan, mengeksplorasi, menguji, mendokumentasikan, dan mereview variasi state UI React secara sistematis.

7 min read1319 words
PrevNext
Lesson 111123 lesson track102–123 Final Stretch
#react#storybook#testing#state-management+2 more

Part 111 — Storybook as State Lab

Storybook bukan sekadar katalog komponen. Untuk engineer senior, Storybook adalah laboratorium state: tempat kita membekukan kombinasi props, context, permission, network state, form state, error state, loading state, optimistic state, dan workflow state menjadi skenario yang bisa dilihat, diuji, direview, dan didiskusikan.

Masalah di aplikasi React besar bukan hanya “komponen ini render atau tidak”. Masalahnya adalah:

Apakah semua state yang mungkin terjadi sudah punya representasi UI?
Apakah state yang berbahaya sudah dicegah?
Apakah loading/error/empty/permission/optimistic state punya desain yang konsisten?
Apakah bug bisa direproduksi tanpa menjalankan seluruh aplikasi?
Apakah reviewer bisa melihat behavior, bukan hanya membaca kode?

Storybook menjadi efektif kalau diperlakukan seperti state simulator. Setiap story bukan demo acak, melainkan satu titik dalam state space.


1. Mental Model: Story as Frozen State Scenario

Di aplikasi penuh, UI berada di tengah banyak dependency:

Di Storybook, kita memotong dependency itu dan membuat environment yang terkendali:

Satu story yang bagus menjawab:

Given environment X,
when component starts in state Y,
and user performs interaction Z,
then observable UI should become W.

Itu sama dengan test, tapi visual, debuggable, dan dokumentatif.


2. Apa yang Harus Masuk Storybook?

Tidak semua hal perlu story. Storybook paling bernilai untuk UI yang punya:

KandidatKenapa cocok untuk Storybook
Design-system primitivePerlu memastikan variant, size, disabled, focus, accessibility
Headless/compound componentBanyak state koordinasi antar child
Form fieldError, touched, disabled, readonly, required, async validation
Data displayLoading, empty, error, partial, stale, optimistic
Modal/drawer/overlayOpen/close, focus, escape, backdrop, async confirm
Workflow componentStep, pending, retry, failed, completed, permission blocked
Permission-aware componentHidden/disabled/readonly/denied-reason
Complex page fragmentQuery state + filter state + action state

Tidak semua page penuh harus masuk Storybook. Yang masuk adalah stateful boundary yang sulit dipercaya hanya dari unit test.


3. Story Taxonomy

Gunakan taxonomy yang konsisten agar story bukan folder sampah.

Component.stories.tsx
  ├─ Default
  ├─ Controlled
  ├─ Uncontrolled
  ├─ Loading
  ├─ Empty
  ├─ Error
  ├─ Disabled
  ├─ PermissionDenied
  ├─ Optimistic
  ├─ WithLongContent
  ├─ WithKeyboardNavigation
  ├─ InDarkTheme
  └─ Regression_<bug-id>

Untuk komponen stateful, story minimal biasanya:

StoryTujuan
Defaultbaseline behavior
Controlledparent-owned state contract
Uncontrolledinternal-owned state contract
Disabledno illegal interaction
Errorerror presentation and recovery
Loadingpending UI
Emptyzero-data state
LongContentoverflow, wrapping, truncation
Keyboardfocus and keyboard semantics
Regression_*bug yang pernah terjadi

Regression stories sangat berharga. Kalau bug sudah pernah terjadi, story-nya harus hidup sebagai dokumentasi.


4. Args: Props as Scenario Parameters

Storybook args adalah cara membuat props menjadi parameter skenario.

import type { Meta, StoryObj } from '@storybook/react';
import { StatusBadge } from './StatusBadge';

const meta = {
  title: 'Components/StatusBadge',
  component: StatusBadge,
  args: {
    status: 'pending',
  },
} satisfies Meta<typeof StatusBadge>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Pending: Story = {
  args: { status: 'pending' },
};

export const Approved: Story = {
  args: { status: 'approved' },
};

export const Rejected: Story = {
  args: { status: 'rejected' },
};

Tetapi args bukan tempat untuk semua state. Gunakan args untuk public component API. Jangan menaruh seluruh fake application state di args kalau component seharusnya menerima view model sederhana.

Bad:

export const Complex: Story = {
  args: {
    entireReduxState: hugeState,
    queryCache: hugeCache,
    routerState: hugeRouterState,
  },
};

Better:

export const PermissionDenied: Story = {
  args: {
    decision: {
      allowed: false,
      reason: 'Only case supervisor can approve this escalation.',
    },
  },
};

Story args harus mendorong API komponen tetap eksplisit.


5. Decorators: Provider Harness, Not Global Magic

Decorators membungkus story dengan environment tambahan: theme, router, query client, permission, feature flags, layout, atau mock provider.

import type { Decorator } from '@storybook/react';
import { ThemeProvider } from '../theme/ThemeProvider';
import { PermissionProvider } from '../permissions/PermissionProvider';

export const withAppProviders: Decorator = (Story, context) => {
  const role = context.parameters.role ?? 'case-worker';

  return (
    <ThemeProvider mode="light">
      <PermissionProvider role={role}>
        <Story />
      </PermissionProvider>
    </ThemeProvider>
  );
};

Gunakan decorator untuk dependency yang memang environment-level. Jangan gunakan decorator untuk menyembunyikan state penting yang seharusnya terlihat sebagai scenario.

export const SupervisorCanApprove: Story = {
  parameters: {
    role: 'supervisor',
  },
};

export const WorkerCannotApprove: Story = {
  parameters: {
    role: 'case-worker',
  },
};

Prinsipnya:

Args menjelaskan input komponen.
Parameters menjelaskan environment story.
Decorators membangun environment itu.
Play function menjalankan interaksi.

6. Story Matrix: Jangan Hanya Happy Path

Untuk komponen production, story harus dibuat dari state matrix.

Contoh komponen CaseActionPanel:

AxisValues
Permissionallowed, denied, readonly
Server stateloading, loaded, error, stale
Workflow phaseidle, confirming, submitting, success, failed
Entity statusopen, escalated, closed
Validationvalid, invalid, missing reason
Viewportdesktop, narrow

Kalau semua dikalikan, jumlah scenario bisa meledak. Jangan buat semua kombinasi. Buat representative coverage:

1 baseline happy path
1 permission denied
1 loading skeleton
1 server error with retry
1 workflow submitting
1 workflow failed with recovery
1 closed entity where action unavailable
1 invalid form state
1 regression story for known bug

Storybook bukan brute-force combinatorial testing. Storybook adalah curated state coverage.


7. State Scenario Builder

Untuk domain UI, jangan menulis mock data manual berulang-ulang. Buat builder.

export function buildCase(overrides: Partial<CaseViewModel> = {}): CaseViewModel {
  return {
    id: 'CASE-1001',
    title: 'Delayed reporting threshold breach',
    status: 'open',
    priority: 'medium',
    assignee: { id: 'u1', name: 'Rina' },
    permissions: {
      canApprove: true,
      canEscalate: true,
      canClose: false,
    },
    ...overrides,
  };
}

Gunakan builder untuk menjaga story tetap fokus:

export const ClosedCase: Story = {
  args: {
    case: buildCase({
      status: 'closed',
      permissions: {
        canApprove: false,
        canEscalate: false,
        canClose: false,
      },
    }),
  },
};

Builder juga membuat data test dan story konsisten. Tapi builder jangan terlalu pintar. Kalau builder mengandung business rule kompleks, story bisa menipu karena data selalu “terlalu bersih”.


8. Storybook for Controlled and Uncontrolled Components

Komponen controllable harus punya story untuk dua mode.

export const Controlled: Story = {
  render: function ControlledStory(args) {
    const [value, setValue] = React.useState('overview');

    return (
      <Tabs
        {...args}
        value={value}
        onValueChange={setValue}
      />
    );
  },
};

export const Uncontrolled: Story = {
  args: {
    defaultValue: 'overview',
  },
};

Test story yang harus ada:

controlled: user click calls onChange and parent updates value
controlled-readonly: user click calls onChange but value stays if parent refuses
uncontrolled: user click updates internal state
reset: changing key resets uncontrolled state

Kenapa ini penting? Karena banyak bug muncul ketika component mengira dirinya controlled, tetapi parent mengira component uncontrolled.


9. Storybook for Context and Providers

Context-heavy components harus punya provider harness yang jelas.

function PermissionStoryHarness({
  role,
  children,
}: {
  role: Role;
  children: React.ReactNode;
}) {
  const permissions = createPermissionDecisionMap(role);

  return (
    <PermissionProvider value={permissions}>
      {children}
    </PermissionProvider>
  );
}

Story:

export const AsSupervisor: Story = {
  decorators: [
    (Story) => (
      <PermissionStoryHarness role="supervisor">
        <Story />
      </PermissionStoryHarness>
    ),
  ],
};

export const AsReadOnlyAuditor: Story = {
  decorators: [
    (Story) => (
      <PermissionStoryHarness role="auditor">
        <Story />
      </PermissionStoryHarness>
    ),
  ],
};

Context story harus menjelaskan:

provider value apa yang dipakai?
role/capability apa yang sedang disimulasikan?
apakah consumer fallback/default value pernah terjadi?
apakah component harus fail fast kalau provider hilang?

Tambahkan story untuk missing provider jika komponen memakai strict context.

export const MissingProvider: Story = {
  render: () => <PermissionAwareButton action="approve" />,
};

Jika strict context melempar error, story ini bisa dibungkus ErrorBoundary agar contract terlihat.


10. Storybook for Server State

Untuk server-state UI, jangan membuat story hanya dengan props kalau real component memang memakai query hook. Ada dua pendekatan:

10.1 Presentation Story

Pisahkan view component dari data hook.

function CaseSummaryView({ state }: { state: CaseSummaryState }) {
  if (state.type === 'loading') return <Skeleton />;
  if (state.type === 'error') return <ErrorMessage error={state.error} />;
  if (state.type === 'empty') return <EmptyState />;
  return <CaseSummary data={state.data} />;
}

Story:

export const Loading: Story = {
  args: {
    state: { type: 'loading' },
  },
};

export const Error: Story = {
  args: {
    state: {
      type: 'error',
      error: new Error('Network timeout'),
    },
  },
};

Ini cepat, stabil, dan bagus untuk UI state coverage.

10.2 Integration Story With Query Client

Untuk menguji hook/data boundary, gunakan harness.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createStoryQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        staleTime: Infinity,
      },
    },
  });
}

export const WithQueryClient: Story = {
  decorators: [
    (Story) => {
      const [client] = React.useState(createStoryQueryClient);

      return (
        <QueryClientProvider client={client}>
          <Story />
        </QueryClientProvider>
      );
    },
  ],
};

Kalau story bergantung pada network, mock network dengan MSW atau module mocks. Jangan biarkan story memanggil backend production.


11. Play Function: User Journey Inside Story

Play function menjalankan interaksi setelah story render.

import { expect, userEvent, within } from '@storybook/test';

export const ApproveHappyPath: Story = {
  args: {
    case: buildCase({ status: 'open' }),
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.click(canvas.getByRole('button', { name: /approve/i }));
    await userEvent.type(canvas.getByLabelText(/reason/i), 'Evidence verified.');
    await userEvent.click(canvas.getByRole('button', { name: /confirm/i }));

    await expect(canvas.getByText(/approval submitted/i)).toBeVisible();
  },
};

Play function cocok untuk:

keyboard navigation
open/close modal
form validation
happy-path workflow
error recovery
permission-blocked interaction
optimistic UI transition
regression reproduction

Jangan membuat play function sebagai E2E test penuh yang menavigasi seluruh aplikasi. Itu membuat story lambat dan rapuh.


12. Storybook as Accessibility State Lab

Accessibility sering gagal karena hanya dites pada happy state. Buat story untuk:

focus visible
keyboard only
screen-reader labels
error messages
aria-invalid
aria-describedby
disabled vs aria-disabled
modal focus trap
escape close policy
loading announcement
empty state landmark

Contoh form field:

export const InvalidRequired: Story = {
  args: {
    label: 'Escalation reason',
    value: '',
    error: 'Reason is required before escalation.',
    required: true,
  },
};

Interaction story:

export const KeyboardNavigation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.tab();
    await expect(canvas.getByRole('tab', { name: /overview/i })).toHaveFocus();

    await userEvent.keyboard('{ArrowRight}');
    await expect(canvas.getByRole('tab', { name: /history/i })).toHaveFocus();
  },
};

Aksesibilitas bukan addon terakhir. Ia adalah bagian dari state contract.


13. Storybook for Workflow State

Workflow yang bagus punya state eksplisit:

type SubmitWorkflow =
  | { tag: 'idle' }
  | { tag: 'editing'; draft: Draft }
  | { tag: 'confirming'; draft: Draft }
  | { tag: 'submitting'; draft: Draft; requestId: string }
  | { tag: 'failed'; draft: Draft; error: string }
  | { tag: 'submitted'; confirmationId: string };

Story per state:

export const Confirming: Story = {
  args: {
    workflow: {
      tag: 'confirming',
      draft: buildDraft({ reason: 'Evidence verified.' }),
    },
  },
};

export const Failed: Story = {
  args: {
    workflow: {
      tag: 'failed',
      draft: buildDraft({ reason: 'Evidence verified.' }),
      error: 'The case was already closed by another reviewer.',
    },
  },
};

Kalau workflow state tidak bisa dibuat sebagai story, biasanya model state-nya terlalu tersebar.

Signal buruk:

Tidak bisa menampilkan failed state tanpa memanggil API asli.
Tidak bisa menampilkan pending state tanpa timing hack.
Tidak bisa menampilkan permission denied tanpa login sebagai role tertentu.
Tidak bisa menampilkan stale data tanpa memodifikasi backend.

Solusinya bukan “Storybook tidak cocok”. Solusinya adalah memisahkan state model dan UI boundary.


14. Storybook for Regression Reproduction

Setiap bug state yang mahal harus punya regression story.

Contoh bug:

Ketika user membuka modal approve, lalu data case berubah menjadi closed, tombol confirm tetap enabled dan command tetap dikirim.

Regression story:

export const Regression_CaseClosedWhileConfirming: Story = {
  args: {
    case: buildCase({ status: 'closed' }),
    initialWorkflow: {
      tag: 'confirming',
      draft: buildDraft({ reason: 'Evidence verified.' }),
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await expect(
      canvas.getByRole('button', { name: /confirm approval/i })
    ).toBeDisabled();

    await expect(
      canvas.getByText(/case is already closed/i)
    ).toBeVisible();
  },
};

Regression story harus diberi nama jelas. Jangan hanya BugFix.

Gunakan format:

Regression_<condition>_<expectedProtection>
Regression_ClosedCase_DisablesApprove
Regression_StaleMutation_ShowsRecovery
Regression_DuplicateSubmit_BlocksSecondCommand

15. Storybook for Design Review

Untuk reviewer desain, story harus menyediakan state yang biasanya sulit dicapai:

empty table with filters applied
error with retry
long translated label
RTL layout
dark mode
small viewport
readonly permission
validation error under focus
modal with nested scroll
optimistic row pending commit

Buat “review board” story:

export const ReviewMatrix: Story = {
  render: () => (
    <div className="grid gap-6">
      <CaseCard case={buildCase({ status: 'open' })} />
      <CaseCard case={buildCase({ status: 'escalated' })} />
      <CaseCard case={buildCase({ status: 'closed' })} />
      <CaseCardSkeleton />
      <CaseCardError message="Cannot load case." />
    </div>
  ),
};

Matrix story bukan test utama. Ia adalah visual alignment tool.


16. Storybook as Architecture Pressure Test

Storybook sering mengungkap arsitektur yang buruk.

Sulit dibuat storyKemungkinan akar masalah
Komponen butuh seluruh app providerBoundary terlalu leaky
Story harus login ke backendPermission tidak injectable
Story harus menunggu timeoutPending state tidak eksplisit
Error state tidak bisa dipaksaError handling tersembunyi di effect
Modal state tidak bisa disetWorkflow state tersebar
Data harus real APIView tidak terpisah dari data source
Banyak decorator globalProvider responsibility tidak terpisah

Storybook bukan hanya output dokumentasi. Ia adalah test apakah boundary UI bisa diisolasi.


17. Storybook Anti-Patterns

17.1 Story as Screenshot Gallery

Story hanya menampilkan Default, tanpa state lain.

Default is not coverage.

17.2 Random Mock Data

Mock data tanpa invariant membuat story tampak valid, tetapi domain state tidak realistis.

17.3 Global Decorator Hell

Semua story dibungkus semua provider aplikasi.

Akibat:

story lambat
scenario tersembunyi
dependency tidak jelas
provider coupling tidak terlihat

17.4 Interaction Test That Tests Implementation

Bad:

await expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_OPEN', value: true });

Better:

await expect(canvas.getByRole('dialog')).toBeVisible();

Test observable behavior, bukan internal dispatch detail.

17.5 Story That Calls Production Backend

Story tidak boleh bergantung pada backend production, data real, atau tenant real.

17.6 One Mega Story

Satu story yang mencoba menunjukkan semua state akan sulit dibaca, sulit dites, dan sulit direview.


Contoh struktur production:

src/features/case-approval/
  ui/
    CaseApprovalPanel.tsx
    CaseApprovalPanel.stories.tsx
    CaseApprovalPanel.test.tsx
  model/
    approvalWorkflow.ts
    approvalWorkflow.test.ts
  testing/
    builders.ts
    storyHarness.tsx

Untuk design system:

src/shared/ui/tabs/
  Tabs.tsx
  Tabs.stories.tsx
  Tabs.a11y.stories.tsx
  Tabs.test.tsx
  Tabs.contract.md

Story dekat dengan component agar story ikut berevolusi bersama API.


19. Story Review Checklist

Sebelum approve komponen stateful, cek:

[ ] Ada Default story.
[ ] Ada loading, empty, error jika data-driven.
[ ] Ada disabled/readonly/permission-denied jika action-driven.
[ ] Ada controlled dan uncontrolled story jika component controllable.
[ ] Ada keyboard interaction story jika interactive primitive.
[ ] Ada long content / overflow story.
[ ] Ada regression story untuk bug yang pernah terjadi.
[ ] Story tidak memanggil backend production.
[ ] Provider/decorator tidak menyembunyikan dependency penting.
[ ] Story menggunakan accessible queries dalam play function.
[ ] State yang ditampilkan sesuai invariant domain.

20. Minimal Production Pattern

Untuk komponen penting, gunakan paket minimal ini:

Component.tsx
Component.stories.tsx
Component.test.tsx
builders.ts
storyHarness.tsx

builders.ts:

export function buildUser(overrides: Partial<UserViewModel> = {}): UserViewModel {
  return {
    id: 'u1',
    name: 'Ayu Pratama',
    role: 'reviewer',
    status: 'active',
    ...overrides,
  };
}

storyHarness.tsx:

export function StoryHarness({
  children,
  role = 'reviewer',
}: {
  children: React.ReactNode;
  role?: Role;
}) {
  return (
    <ThemeProvider mode="light">
      <PermissionProvider value={createPermissions(role)}>
        {children}
      </PermissionProvider>
    </ThemeProvider>
  );
}

Component.stories.tsx:

const meta = {
  title: 'Features/User/UserSummaryCard',
  component: UserSummaryCard,
  decorators: [
    (Story) => (
      <StoryHarness>
        <Story />
      </StoryHarness>
    ),
  ],
} satisfies Meta<typeof UserSummaryCard>;

21. What Good Looks Like

Storybook sebagai state lab berhasil jika:

Engineer bisa mereproduksi bug state tanpa menjalankan seluruh aplikasi.
Designer bisa review edge state, bukan hanya happy path.
QA bisa memahami state matrix.
New engineer bisa belajar component contract dari story.
Regression state hidup sebagai artifact.
Provider dan state ownership terlihat.
Interaction tests memvalidasi behavior penting.

Dan yang paling penting:

Kalau state sulit dibuat story-nya, state model kemungkinan belum cukup eksplisit.

22. Practice

Ambil satu komponen workflow yang pernah sulit dites. Buat:

1 Default story
1 Loading story
1 Empty story
1 Error story
1 PermissionDenied story
1 Optimistic/Pending story
1 FailedRecovery story
1 Regression story dari bug nyata
1 Play function untuk happy path
1 Play function untuk error recovery

Lalu jawab:

State mana yang sulit diset?
Provider mana yang terlalu berat?
Dependency mana yang harus dibuat injectable?
Apakah public API komponen sudah benar?
Apakah story membuat invariant lebih jelas?

Kalau jawabannya menyakitkan, bagus. Storybook sedang melakukan tugasnya sebagai state lab.


23. References

  • Storybook Docs — Play function: https://storybook.js.org/docs/writing-stories/play-function
  • Storybook Docs — Interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing
  • Storybook Docs — Args: https://storybook.js.org/docs/writing-stories/args
  • Storybook Docs — Decorators: https://storybook.js.org/docs/writing-stories/decorators
  • Storybook Docs — Mocking providers: https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-providers
  • React Docs — React Developer Tools: https://react.dev/learn/react-developer-tools
Lesson Recap

You just completed lesson 111 in final stretch. 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.