Build CoreOrdered learning track

Context and Design Systems

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

Context and design systems in production React: theme, semantic tokens, density, direction, form-field context, compound primitives, accessibility contracts, provider layering, CSS variable integration, performance boundaries, testing strategy, and failure modes.

13 min read2408 words
PrevNext
Lesson 37123 lesson track24–67 Build Core
#react#context#design-system#accessibility+2 more

Part 037 — Context and Design Systems

Part sebelumnya membahas Context sebagai capability passing. Sekarang kita pakai mental model itu untuk salah satu area paling nyata di aplikasi besar: design system.

Design system yang matang bukan kumpulan komponen cantik. Design system adalah kontrak UI lintas produk:

visual consistency
+ interaction consistency
+ accessibility consistency
+ composition consistency
+ migration consistency

Context sering muncul di design system karena banyak komponen perlu “beradaptasi terhadap lingkungan” tanpa prop drilling berlebihan.

Contoh:

<AppThemeProvider mode="dark" density="compact" dir="rtl">
  <FormField name="email" error="Email is required">
    <Label>Email</Label>
    <Input />
    <FieldError />
  </FormField>
</AppThemeProvider>

Input tidak perlu menerima semua prop ini secara eksplisit:

<Input
  id="email"
  aria-describedby="email-error"
  aria-invalid={true}
  density="compact"
  dir="rtl"
  colorMode="dark"
/>

Sebagian informasi memang lebih cocok mengalir lewat context karena ia adalah ambient UI environment.

Tetapi ini juga area yang mudah rusak. Context design system yang buruk membuat komponen menjadi magic, sulit dites, sering rerender, dan sulit dipakai di luar provider utama.

Tujuan part ini: memahami Context sebagai infrastruktur design system, bukan sebagai tempat membuang semua state UI.


1. The Core Idea

Context dalam design system biasanya membawa salah satu dari tiga hal:

Jenis contextContohKarakteristik
Environment contexttheme, direction, locale, densityBerlaku untuk subtree luas, jarang berubah
Structural contextform field, tabs, accordion, menuBerlaku untuk compound component kecil
Capability contexttoast, modal, layer manager, focus managerMemberi command terbatas ke subtree

Ketiganya berbeda.

Environment context = "komponen hidup di lingkungan apa?"
Structural context  = "komponen ini bagian dari struktur apa?"
Capability context  = "komponen ini boleh meminta apa?"

Kesalahan umum adalah mencampur semuanya ke satu provider besar.

// Bad smell
<AppContext value={{
  theme,
  density,
  user,
  permissions,
  selectedRows,
  modalState,
  openModal,
  formErrors,
  currentTab,
  queryResult,
}}>
  {children}
</AppContext>

Itu bukan design system. Itu global dumping ground.


2. Mental Model: Design System Context Is Scoped Ambient Input

React component adalah function dari:

props + state + context -> UI

Dalam design system, context sebaiknya merepresentasikan ambient input yang masuk akal dibaca banyak komponen.

Contoh ambient input:

  • theme mode,
  • CSS variable namespace,
  • direction ltr/rtl,
  • density comfortable/compact,
  • form field id,
  • disabled/readOnly state dari field group,
  • validation status,
  • compound component registry,
  • layer/z-index manager,
  • focus scope.

Ambient input berbeda dari business data.

Good context:
  Button membaca density dari DesignSystemProvider.

Bad context:
  Button membaca currentInvoice dari AppContext.

Sebuah komponen design system harus bisa dipakai di banyak domain. Begitu ia membaca data domain langsung, ia berhenti menjadi primitive dan berubah menjadi product component.


3. Provider Layering in a Design System

Aplikasi production biasanya punya beberapa lapisan provider.

Jangan semua provider harus global. Gunakan scope sesuai kebutuhan.

ProviderScope idealKenapa
Themeapp atau product areaBanyak komponen butuh mode/tokens
Directionapp atau pageLayout dan keyboard behavior bisa berubah
Densityapp, page, panel, tableBisa beda antar area
ToastappBiasanya command global
Modal layerappButuh portal root dan stacking global
Form fieldfield-levelHanya label/input/error di field itu
Tabstabs rootHanya tab parts terkait
Menumenu rootHanya menu parts terkait
Table statetable root atau external storeBiasanya terlalu volatile untuk context global

Prinsipnya:

Provider scope harus sekecil mungkin, tapi sebesar yang diperlukan.

4. Theme Context: What Should It Contain?

Theme context sering menjadi provider pertama dalam design system.

Namun ada dua jenis theme data:

DataCocok di Context?Lebih cocok di CSS?
Current mode: light/dark/systemYaKadang juga attribute DOM
Token values: colors, spacing, radiusTidak selaluYa, CSS variables
Runtime setter: setModeYa, sebagai capabilityTidak
Resolved modeYa, jika perlu JS behaviorBisa via DOM attr
Full token map besarHati-hatiBiasanya CSS variables

Kesalahan umum:

<ThemeContext value={{
  colors: hugeColorObject,
  spacing: hugeSpacingObject,
  typography: hugeTypographyObject,
  mode,
  setMode,
}}>

Setiap perubahan identity object dapat menyentuh semua consumer. Untuk design system modern, token visual lebih baik diekspresikan sebagai CSS custom properties.

:root {
  --ds-color-bg: white;
  --ds-color-fg: black;
  --ds-radius-md: 8px;
}

[data-theme="dark"] {
  --ds-color-bg: #0b0b0f;
  --ds-color-fg: #f5f5f5;
}

Lalu React context hanya membawa state/command kecil.

import { createContext, useContext, useMemo, useState } from 'react';

type ThemeMode = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';

type ThemeContextValue = {
  mode: ThemeMode;
  resolvedTheme: ResolvedTheme;
  setMode: (mode: ThemeMode) => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [mode, setMode] = useState<ThemeMode>('system');

  // Simplified. Real implementation would subscribe to prefers-color-scheme.
  const resolvedTheme: ResolvedTheme = mode === 'system' ? 'light' : mode;

  const value = useMemo(
    () => ({ mode, resolvedTheme, setMode }),
    [mode, resolvedTheme]
  );

  return (
    <ThemeContext value={value}>
      <div data-theme={resolvedTheme}>{children}</div>
    </ThemeContext>
  );
}

export function useTheme() {
  const value = useContext(ThemeContext);
  if (!value) {
    throw new Error('useTheme must be used inside ThemeProvider');
  }
  return value;
}

Catatan penting: jika styling bisa diselesaikan oleh CSS, jangan memaksa semua component membaca Context hanya untuk menentukan class.

// Often unnecessary
function Button() {
  const { resolvedTheme } = useTheme();
  return <button className={resolvedTheme === 'dark' ? 'darkBtn' : 'lightBtn'} />;
}

// Better for many visual concerns
function Button(props: ButtonProps) {
  return <button className="ds-button" {...props} />;
}

CSS variables membiarkan browser menyelesaikan styling tanpa React rerender.


5. Density Context

Density adalah contoh bagus untuk scoped environment context.

Aplikasi enterprise sering punya mode:

comfortable -> spacing besar, mudah dibaca
compact     -> spacing kecil, cocok data-dense admin UI

Density bisa global, tapi sering lebih baik scoped.

<DensityProvider value="compact">
  <CaseTable />
</DensityProvider>

<DensityProvider value="comfortable">
  <CaseSummaryCard />
</DensityProvider>

Implementasi sederhana:

type Density = 'comfortable' | 'compact';

const DensityContext = createContext<Density>('comfortable');

export function DensityProvider({
  value,
  children,
}: {
  value: Density;
  children: React.ReactNode;
}) {
  return (
    <DensityContext value={value}>
      <div data-density={value}>{children}</div>
    </DensityContext>
  );
}

export function useDensity() {
  return useContext(DensityContext);
}

Untuk sebagian besar styling, gunakan data-density + CSS variables.

[data-density="comfortable"] {
  --ds-control-height: 40px;
  --ds-control-padding-x: 12px;
}

[data-density="compact"] {
  --ds-control-height: 32px;
  --ds-control-padding-x: 8px;
}

.ds-input {
  min-height: var(--ds-control-height);
  padding-inline: var(--ds-control-padding-x);
}

Komponen hanya perlu membaca density jika behavior berubah, bukan hanya tampilan.

Contoh behavior:

function DataGridVirtualizer() {
  const density = useDensity();
  const estimatedRowHeight = density === 'compact' ? 32 : 44;

  return <VirtualizedTable estimatedRowHeight={estimatedRowHeight} />;
}

Rule:

Visual adaptation -> CSS variables / data attributes.
Behavioral adaptation -> Context read boleh dipakai.

6. Direction Context: LTR, RTL, and Keyboard Behavior

Direction bukan sekadar CSS.

Untuk dir="rtl", beberapa keyboard interaction berubah makna. Misalnya horizontal navigation dalam tablist atau carousel bisa perlu membalik interpretasi ArrowLeft/ArrowRight.

type Direction = 'ltr' | 'rtl';

const DirectionContext = createContext<Direction>('ltr');

export function DirectionProvider({
  dir,
  children,
}: {
  dir: Direction;
  children: React.ReactNode;
}) {
  return (
    <DirectionContext value={dir}>
      <div dir={dir}>{children}</div>
    </DirectionContext>
  );
}

export function useDirection() {
  return useContext(DirectionContext);
}

Keyboard helper:

function getHorizontalIntent(event: KeyboardEvent, dir: 'ltr' | 'rtl') {
  if (event.key === 'ArrowRight') {
    return dir === 'rtl' ? 'previous' : 'next';
  }

  if (event.key === 'ArrowLeft') {
    return dir === 'rtl' ? 'next' : 'previous';
  }

  return null;
}

Jangan menyebarkan dir manual ke semua component. Direction adalah ambient UI environment yang cocok untuk Context.


7. Form Field Context

Form field adalah contoh structural context paling penting.

Masalah yang ingin diselesaikan:

<label htmlFor="email">Email</label>
<input id="email" aria-describedby="email-hint email-error" aria-invalid="true" />
<p id="email-hint">Use your work email.</p>
<p id="email-error">Email is required.</p>

Jika semua id dikirim manual via props, API form menjadi berisik dan mudah salah.

Field context membantu menyatukan:

  • id,
  • name,
  • labelId,
  • descriptionId,
  • errorId,
  • required,
  • disabled,
  • readOnly,
  • invalid,
  • error message,
  • described-by composition.
import { createContext, useContext, useId, useMemo } from 'react';

type FieldContextValue = {
  id: string;
  name?: string;
  labelId: string;
  descriptionId: string;
  errorId: string;
  invalid: boolean;
  required: boolean;
  disabled: boolean;
  describedBy: string | undefined;
};

const FieldContext = createContext<FieldContextValue | null>(null);

export function Field({
  id: explicitId,
  name,
  error,
  required = false,
  disabled = false,
  children,
}: {
  id?: string;
  name?: string;
  error?: string;
  required?: boolean;
  disabled?: boolean;
  children: React.ReactNode;
}) {
  const generatedId = useId();
  const id = explicitId ?? generatedId;
  const invalid = Boolean(error);

  const labelId = `${id}-label`;
  const descriptionId = `${id}-description`;
  const errorId = `${id}-error`;

  const describedBy = invalid
    ? `${descriptionId} ${errorId}`
    : descriptionId;

  const value = useMemo(
    () => ({
      id,
      name,
      labelId,
      descriptionId,
      errorId,
      invalid,
      required,
      disabled,
      describedBy,
    }),
    [id, name, labelId, descriptionId, errorId, invalid, required, disabled, describedBy]
  );

  return <FieldContext value={value}>{children}</FieldContext>;
}

export function useFieldContext(componentName: string) {
  const value = useContext(FieldContext);
  if (!value) {
    throw new Error(`${componentName} must be used inside <Field>`);
  }
  return value;
}

Field parts:

export function Label({ children }: { children: React.ReactNode }) {
  const field = useFieldContext('Label');

  return (
    <label id={field.labelId} htmlFor={field.id}>
      {children}
      {field.required ? <span aria-hidden="true"> *</span> : null}
    </label>
  );
}

export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
  const field = useFieldContext('Input');

  return (
    <input
      {...props}
      id={field.id}
      name={props.name ?? field.name}
      disabled={props.disabled ?? field.disabled}
      required={props.required ?? field.required}
      aria-labelledby={field.labelId}
      aria-describedby={field.describedBy}
      aria-invalid={field.invalid || undefined}
    />
  );
}

export function FieldDescription({ children }: { children: React.ReactNode }) {
  const field = useFieldContext('FieldDescription');
  return <p id={field.descriptionId}>{children}</p>;
}

export function FieldError({ children }: { children?: React.ReactNode }) {
  const field = useFieldContext('FieldError');

  if (!field.invalid && !children) return null;

  return (
    <p id={field.errorId} role="alert">
      {children}
    </p>
  );
}

Ini contoh Context yang sehat:

  • scope kecil,
  • value relevan untuk semua part,
  • tidak global,
  • membantu accessibility,
  • API lebih bersih,
  • failure ketika salah pakai jelas.

8. Field Context and Form Libraries

Jangan campuradukkan field context design system dengan form state library.

Form library state:
  value, touched, dirty, validation lifecycle, submission lifecycle

Design system field context:
  id, label linkage, invalid flag, required flag, disabled flag, described-by ids

Design system tidak perlu tahu react-hook-form, Formik, TanStack Form, atau state manager lain.

Lebih baik buat adapter di layer aplikasi.

function CaseFormTextField({ name, label }: { name: string; label: string }) {
  const field = useController({ name });
  const error = field.fieldState.error?.message;

  return (
    <Field name={name} error={error} required>
      <Label>{label}</Label>
      <Input
        value={field.field.value ?? ''}
        onChange={field.field.onChange}
        onBlur={field.field.onBlur}
      />
      <FieldError>{error}</FieldError>
    </Field>
  );
}

Dengan begini:

  • design system tetap library-agnostic,
  • aplikasi bebas memilih form engine,
  • accessibility contract tetap terpusat,
  • migration form library tidak merusak semua primitive.

9. Disabled, ReadOnly, and Required Context

Banyak design system butuh group-level state.

<Fieldset disabled>
  <TextField name="firstName" />
  <TextField name="lastName" />
</Fieldset>

Kita bisa punya FormControlContext atau FieldsetContext.

type FieldsetContextValue = {
  disabled: boolean;
  readOnly: boolean;
};

const FieldsetContext = createContext<FieldsetContextValue>({
  disabled: false,
  readOnly: false,
});

export function Fieldset({
  disabled = false,
  readOnly = false,
  children,
}: {
  disabled?: boolean;
  readOnly?: boolean;
  children: React.ReactNode;
}) {
  const value = useMemo(() => ({ disabled, readOnly }), [disabled, readOnly]);

  return (
    <FieldsetContext value={value}>
      <fieldset disabled={disabled}>{children}</fieldset>
    </FieldsetContext>
  );
}

export function useFieldsetContext() {
  return useContext(FieldsetContext);
}

Lalu field bisa merge state dari fieldset dan props lokal.

function TextInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
  const fieldset = useFieldsetContext();

  return (
    <input
      {...props}
      disabled={props.disabled ?? fieldset.disabled}
      readOnly={props.readOnly ?? fieldset.readOnly}
    />
  );
}

Tetapi hati-hati: merge rule harus eksplisit.

Local prop override group context?
Group context override local prop?
Apakah disabled bisa di-enable lagi di child?
Apakah readonly berbeda dari disabled?

Untuk state yang berdampak accessibility, kontraknya harus konsisten.


10. Compound Component Context in Design Systems

Banyak primitive design system memakai compound component:

<Tabs defaultValue="details">
  <Tabs.List>
    <Tabs.Trigger value="details">Details</Tabs.Trigger>
    <Tabs.Trigger value="history">History</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="details">...</Tabs.Panel>
  <Tabs.Panel value="history">...</Tabs.Panel>
</Tabs>

Context menghubungkan parts:

Contoh simplified:

type TabsContextValue = {
  value: string;
  setValue: (value: string) => void;
  baseId: string;
};

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabsContext(component: string) {
  const value = useContext(TabsContext);
  if (!value) throw new Error(`${component} must be used inside <Tabs>`);
  return value;
}

export function Tabs({
  value: controlledValue,
  defaultValue,
  onValueChange,
  children,
}: {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
}) {
  const baseId = useId();
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? '');

  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : uncontrolledValue;

  const setValue = useCallback(
    (nextValue: string) => {
      if (!isControlled) setUncontrolledValue(nextValue);
      onValueChange?.(nextValue);
    },
    [isControlled, onValueChange]
  );

  const contextValue = useMemo(
    () => ({ value, setValue, baseId }),
    [value, setValue, baseId]
  );

  return <TabsContext value={contextValue}>{children}</TabsContext>;
}

Trigger:

export function TabsTrigger({
  value,
  children,
}: {
  value: string;
  children: React.ReactNode;
}) {
  const tabs = useTabsContext('TabsTrigger');
  const selected = tabs.value === value;
  const triggerId = `${tabs.baseId}-trigger-${value}`;
  const panelId = `${tabs.baseId}-panel-${value}`;

  return (
    <button
      id={triggerId}
      role="tab"
      type="button"
      aria-selected={selected}
      aria-controls={panelId}
      tabIndex={selected ? 0 : -1}
      onClick={() => tabs.setValue(value)}
    >
      {children}
    </button>
  );
}

Panel:

export function TabsPanel({
  value,
  children,
}: {
  value: string;
  children: React.ReactNode;
}) {
  const tabs = useTabsContext('TabsPanel');
  const selected = tabs.value === value;
  const triggerId = `${tabs.baseId}-trigger-${value}`;
  const panelId = `${tabs.baseId}-panel-${value}`;

  return (
    <div
      id={panelId}
      role="tabpanel"
      aria-labelledby={triggerId}
      hidden={!selected}
    >
      {selected ? children : null}
    </div>
  );
}

Context di sini bukan global state. Ini structural coordination.


11. Accessibility Contract Is Part of the Design System

Design system context sering berfungsi untuk menjaga accessibility linkage.

Contoh linkage:

PatternLinkage
Fieldlabel ↔ input ↔ description ↔ error
Tabstab ↔ tabpanel
Accordionheader/button ↔ panel
Menubutton ↔ popup menu, active item
Dialogtrigger/open command ↔ title/description/focus scope
Tooltiptrigger ↔ tooltip content
Comboboxinput ↔ listbox ↔ active option

Context membantu karena setiap part butuh id dan state yang sama.

Namun context tidak menggantikan pemahaman ARIA. Misalnya untuk Tabs, kita tetap perlu role, id linkage, keyboard interaction, focus behavior, dan activation model yang benar.

Context coordinates data.
ARIA defines semantics.
Keyboard logic defines interaction.
Focus management defines usability.

Design system yang baik menyembunyikan kompleksitas ini dari product engineer.


12. Data Attributes as Public Styling Surface

Context sering dipakai oleh component untuk menentukan data attributes.

<button
  data-state={selected ? 'active' : 'inactive'}
  data-disabled={disabled ? '' : undefined}
  data-density={density}
/>

Keuntungan:

  • styling bisa dilakukan tanpa membaca React context di child lain,
  • state visual dapat terlihat di DOM,
  • testing lebih mudah,
  • integrasi CSS lebih stabil,
  • design token bisa di-layer.

Contoh:

.ds-tabs-trigger[data-state="active"] {
  border-block-end-color: var(--ds-color-accent);
}

.ds-button[data-density="compact"] {
  min-height: 32px;
}

Tetapi jangan jadikan data attributes sebagai domain state bocor.

// Bad smell
<div data-current-invoice-status="under-investigation" />

Untuk design system, data attribute sebaiknya menyatakan UI state, bukan domain detail.


13. Design Tokens: Context vs CSS Variables

Gunakan aturan ini:

Token visual stabil      -> CSS variables
Mode/current selection   -> Context or DOM attribute
Behavioral decision      -> Context
Huge token dictionary    -> Avoid in context

Contoh buruk:

const token = useContext(TokenContext);
return <div style={{ color: token.colors.text.default }} />;

Itu membuat banyak component bergantung ke object token JS.

Contoh lebih baik:

return <div className="ds-card" />;
.ds-card {
  color: var(--ds-color-text-default);
  background: var(--ds-color-surface-default);
  border-radius: var(--ds-radius-md);
}

Jika token perlu dihitung di runtime, batasi scope-nya.

<ColorSchemeProvider scheme={customerBrandScheme}>
  <PartnerPortal />
</ColorSchemeProvider>

Jangan membuat semua component membaca semua token hanya karena “bisa”.


14. Slot Context

Beberapa design system memakai slot context untuk menyebarkan props dari parent ke bagian tertentu.

Contoh:

<Card>
  <Card.Header />
  <Card.Body />
  <Card.Footer />
</Card>

Card.Header mungkin perlu tahu variant/density/elevation dari Card.

type CardContextValue = {
  variant: 'plain' | 'outlined' | 'elevated';
  density: 'comfortable' | 'compact';
};

const CardContext = createContext<CardContextValue | null>(null);

function Card({
  variant = 'plain',
  density = 'comfortable',
  children,
}: {
  variant?: CardContextValue['variant'];
  density?: CardContextValue['density'];
  children: React.ReactNode;
}) {
  const value = useMemo(() => ({ variant, density }), [variant, density]);

  return (
    <CardContext value={value}>
      <section data-variant={variant} data-density={density} className="ds-card">
        {children}
      </section>
    </CardContext>
  );
}

function CardHeader({ children }: { children: React.ReactNode }) {
  const card = useContext(CardContext);

  return (
    <header
      className="ds-card-header"
      data-card-variant={card?.variant}
      data-density={card?.density}
    >
      {children}
    </header>
  );
}

Tetapi pertimbangkan apakah context benar-benar perlu. Jika CSS descendant selector cukup, jangan tambahkan Context.

.ds-card[data-density="compact"] .ds-card-header {
  padding: 8px;
}

Rule:

Jika child hanya perlu styling dari parent, CSS sering lebih murah dari Context.
Jika child perlu behavior/ARIA/id/registration, Context lebih masuk akal.

15. Registry Context

Beberapa komponen headless perlu mendaftarkan item.

Contoh:

  • menu items,
  • listbox options,
  • roving focus items,
  • accordion items,
  • dynamic tabs,
  • command palette items.

Context membawa registry API.

type ItemRecord = {
  id: string;
  ref: React.RefObject<HTMLElement | null>;
  disabled: boolean;
};

type RovingFocusContextValue = {
  registerItem: (item: ItemRecord) => () => void;
  moveNext: () => void;
  movePrevious: () => void;
  activeId: string | null;
};

Important: registry biasanya mutable internal, bukan state yang harus merender semua consumer setiap perubahan kecil.

function RovingFocusProvider({ children }: { children: React.ReactNode }) {
  const itemsRef = useRef<Map<string, ItemRecord>>(new Map());
  const [activeId, setActiveId] = useState<string | null>(null);

  const registerItem = useCallback((item: ItemRecord) => {
    itemsRef.current.set(item.id, item);

    return () => {
      itemsRef.current.delete(item.id);
    };
  }, []);

  const moveNext = useCallback(() => {
    const items = Array.from(itemsRef.current.values()).filter(item => !item.disabled);
    if (items.length === 0) return;

    const currentIndex = items.findIndex(item => item.id === activeId);
    const next = items[(currentIndex + 1) % items.length];
    setActiveId(next.id);
    next.ref.current?.focus();
  }, [activeId]);

  const movePrevious = useCallback(() => {
    const items = Array.from(itemsRef.current.values()).filter(item => !item.disabled);
    if (items.length === 0) return;

    const currentIndex = items.findIndex(item => item.id === activeId);
    const previous = items[(currentIndex - 1 + items.length) % items.length];
    setActiveId(previous.id);
    previous.ref.current?.focus();
  }, [activeId]);

  const value = useMemo(
    () => ({ registerItem, moveNext, movePrevious, activeId }),
    [registerItem, moveNext, movePrevious, activeId]
  );

  return <RovingFocusContext value={value}>{children}</RovingFocusContext>;
}

Perhatikan trade-off: karena activeId ada di context, semua consumer context bisa rerender saat active item berubah. Untuk menu kecil, ini baik-baik saja. Untuk list besar, gunakan external store atau subscription granular.


16. Layer Manager Context

Design system sering perlu mengatur overlay:

  • Dialog,
  • Popover,
  • Tooltip,
  • Dropdown menu,
  • Toast,
  • Command palette.

Masalah overlay bukan hanya render.

Masalahnya:

portal host
+ stacking order
+ escape key
+ outside click
+ focus return
+ inert background
+ scroll lock
+ nested overlay
+ dismissal policy

Layer manager context bisa memberi capability:

type LayerManager = {
  registerLayer: (layer: LayerRecord) => () => void;
  isTopLayer: (id: string) => boolean;
  dismissTopLayer: () => void;
};

Overlay component membaca capability itu.

function Dialog({ open, onOpenChange, children }: DialogProps) {
  const layerManager = useLayerManager();
  const id = useId();

  useEffect(() => {
    if (!open) return;

    return layerManager.registerLayer({
      id,
      type: 'dialog',
      dismiss: () => onOpenChange(false),
    });
  }, [open, id, layerManager, onOpenChange]);

  if (!open) return null;

  return createPortal(
    <div role="dialog" aria-modal="true">
      {children}
    </div>,
    document.body
  );
}

Context di sini bukan menyebarkan semua modal state. Context hanya menyebarkan layer coordination capability.


17. The Design System Boundary

Sebuah design system harus punya batas domain.

Design system component:
  Button, TextField, Dialog, Tabs, Menu, DataTable primitive

Product component:
  CaseStatusBadge, EnforcementActionPanel, InvoiceApprovalTimeline

Context design system tidak boleh membaca domain context secara langsung.

// Bad smell
function Button(props: ButtonProps) {
  const permission = usePermission();
  const caseStatus = useCaseStatus();

  return <button disabled={!permission.canEditCase || caseStatus === 'closed'} />;
}

Lebih baik:

function Button(props: ButtonProps) {
  return <button {...props} />;
}

function EditCaseButton({ caseId }: { caseId: string }) {
  const permission = useCasePermission(caseId);

  return (
    <Button disabled={!permission.canEdit}>
      Edit case
    </Button>
  );
}

Design system boleh support permission-related visual states, tetapi bukan memutuskan domain permission.

<Button disabled tooltip="You do not have permission to edit this case">
  Edit
</Button>

18. Product Theme Overrides

Di enterprise, satu aplikasi bisa punya area berbeda:

  • admin console,
  • public portal,
  • partner portal,
  • enforcement workspace,
  • reporting dashboard.

Masing-masing bisa override theme/density/layout.

<DesignSystemProvider>
  <PublicPortalTheme>
    <PublicRoutes />
  </PublicPortalTheme>

  <WorkspaceTheme density="compact">
    <InternalRoutes />
  </WorkspaceTheme>
</DesignSystemProvider>

Context override harus predictable.

Provider terdekat menang.
Nested provider tidak boleh membuat state parent corrupt.
Override harus documented.

Contoh:

function Surface({ tone = 'default', children }: SurfaceProps) {
  return (
    <SurfaceToneContext value={tone}>
      <section data-surface-tone={tone}>{children}</section>
    </SurfaceToneContext>
  );
}

Komponen di dalam surface bisa adapt.

function Divider() {
  const tone = useContext(SurfaceToneContext);
  return <hr data-surface-tone={tone} />;
}

Tetapi jangan terlalu banyak override yang saling bertumpuk tanpa observability. UI bisa menjadi sulit ditebak.


19. Context and CSS Cascade: Use Both Deliberately

Context dan CSS cascade memecahkan masalah berbeda.

MasalahCSS cascadeReact Context
Warna berdasarkan themeSangat cocokKadang perlu untuk JS behavior
Padding berdasarkan densitySangat cocokKadang perlu untuk virtualization
Direction layoutCocok via dirPerlu untuk keyboard behavior
ARIA id linkageTidakSangat cocok
Register menu itemTidakCocok
Open modal commandTidakCocok
Token visual besarCocok via variablesHindari object besar
Read current feature flagTidakCocok jika scoped

React engineer yang matang tidak memindahkan semua hal ke JavaScript. Browser punya mekanisme sendiri yang lebih murah untuk styling.

Rule praktis:

Style belongs to CSS when possible.
Behavior coordination belongs to React.
Cross-cutting scoped capability belongs to Context.

20. Performance Model for Design System Context

Context design system biasanya aman jika:

  • value kecil,
  • update jarang,
  • consumer ringan,
  • scope provider kecil,
  • styling tidak memaksa rerender,
  • high-frequency state tidak dimasukkan ke context.

Risiko muncul ketika context membawa:

  • hover/focus state banyak item,
  • scroll position,
  • mouse coordinates,
  • active cell dalam grid besar,
  • query result besar,
  • entire form values,
  • entire app config object yang berubah identity,
  • token dictionary besar yang dibuat ulang.

Pakai rumus dari Part 035:

cost = update frequency × consumer fan-out × render weight × interaction sensitivity

Contoh buruk:

<GridContext value={{ hoveredRowId, selectedRows, columns, rows, sort, filter }}>
  {children}
</GridContext>

Hover row berubah sangat sering. Jika semua cell membaca context yang sama, table besar akan bermasalah.

Lebih baik:

  • context hanya untuk static grid configuration dan command API,
  • row/cell subscription pakai external store atau props granular,
  • visual hover pakai CSS jika bisa,
  • selection pakai selector-based subscription untuk list besar.

21. Design System Context API Checklist

Sebelum menambahkan Context baru ke design system, jawab ini:

1. Apakah value ini benar-benar ambient untuk subtree?
2. Apakah value ini domain-agnostic?
3. Apakah scope provider sudah sekecil mungkin?
4. Apakah update frequency rendah?
5. Apakah styling bisa diselesaikan via CSS variables/data attributes?
6. Apakah context membawa object besar?
7. Apakah ada default value yang bisa menutupi missing provider bug?
8. Apakah consumer punya custom hook dengan error message jelas?
9. Apakah context mempermudah accessibility contract?
10. Apakah context bisa di-override di test/storybook?
11. Apakah provider menyebabkan remount tidak sengaja?
12. Apakah ada migration path jika context menjadi bottleneck?

Jika banyak jawaban tidak jelas, jangan buru-buru menambah context.


22. Testing Design System Context

Test design system context pada level behavior, bukan snapshot styling saja.

22.1 Field context test

it('connects label, input, description, and error', () => {
  render(
    <Field error="Required" required>
      <Label>Email</Label>
      <Input />
      <FieldDescription>Use your work email.</FieldDescription>
      <FieldError>Required</FieldError>
    </Field>
  );

  const input = screen.getByLabelText(/email/i);

  expect(input).toHaveAttribute('aria-invalid', 'true');
  expect(input).toHaveAccessibleDescription(/use your work email/i);
  expect(screen.getByRole('alert')).toHaveTextContent('Required');
});

22.2 Direction behavior test

it('reverses horizontal intent in RTL mode', async () => {
  render(
    <DirectionProvider dir="rtl">
      <Tabs defaultValue="one">
        <Tabs.List>
          <Tabs.Trigger value="one">One</Tabs.Trigger>
          <Tabs.Trigger value="two">Two</Tabs.Trigger>
        </Tabs.List>
      </Tabs>
    </DirectionProvider>
  );

  await user.keyboard('{ArrowLeft}');

  expect(screen.getByRole('tab', { name: /two/i })).toHaveAttribute('aria-selected', 'true');
});

22.3 Provider override test

it('uses nearest density provider', () => {
  render(
    <DensityProvider value="comfortable">
      <Button>Comfortable</Button>
      <DensityProvider value="compact">
        <Button>Compact</Button>
      </DensityProvider>
    </DensityProvider>
  );

  expect(screen.getByRole('button', { name: /comfortable/i })).toHaveAttribute(
    'data-density',
    'comfortable'
  );

  expect(screen.getByRole('button', { name: /compact/i })).toHaveAttribute(
    'data-density',
    'compact'
  );
});

23. Storybook as Context Laboratory

Storybook bagus untuk menguji variasi context.

Buat decorator:

export const decorators = [
  (Story, context) => (
    <ThemeProvider initialMode={context.globals.theme}>
      <DensityProvider value={context.globals.density}>
        <DirectionProvider dir={context.globals.dir}>
          <Story />
        </DirectionProvider>
      </DensityProvider>
    </ThemeProvider>
  ),
];

Lalu setiap component diuji pada matrix:

light/dark
comfortable/compact
ltr/rtl
valid/invalid
enabled/disabled/readOnly
controlled/uncontrolled
keyboard/mouse/touch

Design system bug sering hanya muncul pada kombinasi context tertentu.


24. Anti-Example: Theme Context Doing Too Much

type BadThemeContextValue = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  spacing: Record<string, number>;
  colors: Record<string, string>;
  typography: Record<string, unknown>;
  currentUserBrand: Brand;
  permissions: PermissionMap;
  featureFlags: FeatureFlagMap;
  isSidebarOpen: boolean;
  setSidebarOpen: (open: boolean) => void;
};

Masalah:

  • theme dicampur dengan user/domain/layout,
  • object besar membuat identity sulit stabil,
  • consumer tidak jelas membaca apa,
  • rerender propagation luas,
  • testing perlu provider raksasa,
  • design system bergantung pada product domain.

Refactor:

ThemeProvider        -> mode/resolved mode + CSS variable boundary
DensityProvider      -> density only
PermissionProvider   -> product/application layer, bukan design system primitive
FeatureFlagProvider  -> capability/config layer
SidebarProvider      -> layout boundary

25. Anti-Example: Field Context Holding Field Value

<FieldContext value={{ value, setValue, error, touched, dirty, id }}>

Ini terlihat praktis, tetapi berbahaya jika design system field menjadi form engine tersembunyi.

Masalah:

  • semua field parts bisa rerender saat value berubah,
  • design system terkunci ke state model tertentu,
  • sulit integrasi dengan form library,
  • uncontrolled input menjadi sulit,
  • validation lifecycle bercampur dengan ARIA/id linkage.

Lebih baik:

Design system FieldContext:
  id, describedBy, invalid, disabled, required

Form adapter:
  value, onChange, onBlur, error, touched, dirty

26. Anti-Example: Context-Only Styling

function Text({ children }: { children: React.ReactNode }) {
  const theme = useTheme();
  const density = useDensity();

  return (
    <span style={{
      color: theme.colors.text,
      fontSize: density === 'compact' ? 12 : 14,
    }}>
      {children}
    </span>
  );
}

Ini memindahkan cascade CSS ke render React.

Lebih baik:

function Text({ children }: { children: React.ReactNode }) {
  return <span className="ds-text">{children}</span>;
}
.ds-text {
  color: var(--ds-color-text-default);
  font-size: var(--ds-font-size-body);
}

Context boleh menentukan attribute root:

<div data-theme={resolvedTheme} data-density={density}>{children}</div>

Lalu CSS menyelesaikan detail visual.


27. Decision Matrix

KebutuhanPakai propsPakai ContextPakai CSS vars/data attrPakai external store
Button variant eksplisitYaTidakBisaTidak
Theme mode subtreeKadangYaYaTidak
Color token valuesTidakJarangYaTidak
Field id linkageBisa, tapi noisyYaTidakTidak
Form value setiap inputYa/adaptorJangan di DS contextTidakKadang
Active tab small componentBisaYaData attr outputTidak
Active row huge gridTidak cukupHati-hatiCSS hoverYa
Modal open commandProp atau contextYaTidakKadang
Toast commandTidak praktisYaTidakKadang
Permission decision domainProduct props/hookProduct contextTidakKadang
Design-system disabled groupBisaYaData attr outputTidak

28. Failure Modes

28.1 Provider too wide

Provider global membawa state yang hanya dipakai satu feature.

Symptom:
  Semua aplikasi rerender saat satu panel berubah.

Fix:
  Turunkan provider ke feature boundary.

28.2 Context replaces CSS cascade

Semua component membaca theme context untuk styling.

Symptom:
  Theme switch membuat semua component rerender berat.

Fix:
  Pindahkan token visual ke CSS variables.

28.3 Design system reads domain context

Primitive membaca user/permission/case/invoice langsung.

Symptom:
  Button tidak bisa dipakai di luar app tertentu.

Fix:
  Pindahkan domain decision ke product component.

28.4 Field context becomes form engine

Field context menyimpan value/touched/dirty/validation lifecycle.

Symptom:
  Design system sulit diganti form library.

Fix:
  Pisahkan field accessibility context dari form state adapter.

28.5 Compound context becomes global store

Tabs/Menu/Accordion context dipakai di luar root kecil.

Symptom:
  Parts saling bergantung secara tidak jelas.

Fix:
  Batasi scope, enforce provider, expose public API eksplisit.

28.6 Unstable provider value

Provider membuat object/function baru tanpa alasan.

Symptom:
  Consumer rerender walau value logis tidak berubah.

Fix:
  Memoize value atau split context.

28.7 Missing provider silently uses default

Default value terlihat valid, bug tidak langsung muncul.

Symptom:
  Component behave salah di test/storybook tanpa error.

Fix:
  Gunakan strict context untuk context yang wajib.

29. Production Checklist

Gunakan checklist ini saat review design system context:

[ ] Context name spesifik, bukan AppContext/UIContext generic.
[ ] Value kecil dan jelas.
[ ] Scope provider minimal.
[ ] Styling utama memakai CSS variables/data attributes jika memungkinkan.
[ ] Context tidak membawa domain data.
[ ] Context tidak membawa high-frequency state besar.
[ ] Custom hook memberi error jelas saat provider hilang.
[ ] Provider value identity stabil.
[ ] Ada override story/test harness.
[ ] Accessibility contract dites.
[ ] RTL/density/theme variations dites.
[ ] Ada documented merge rule antara props lokal dan group context.
[ ] Ada migration path jika context menjadi bottleneck.

30. Key Takeaways

Design system context yang baik bukan tempat global state. Ia adalah mekanisme untuk menyebarkan environment, structure, dan capability dengan scope jelas.

Use Context for:
  ambient UI environment,
  compound component coordination,
  accessibility linkage,
  scoped capabilities.

Avoid Context for:
  token dictionary besar,
  high-frequency UI state,
  domain data,
  form value engine tersembunyi,
  styling yang bisa diselesaikan CSS.

Kalau component hanya perlu warna, ukuran, spacing, atau radius, sering kali CSS variables lebih tepat daripada useContext. Kalau component perlu id linkage, keyboard coordination, overlay manager, atau provider override, Context adalah alat yang sangat kuat.

React engineer senior tahu perbedaan itu.


References

Lesson Recap

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