Modh
AboutServicesWorkPlaybookResourcesBook a call
Book a call
Agent Skills/React / Next.js/React Component Architecture
React / Next.jsadvanced10 min

component React Component Architecture

React component decomposition, hook extraction, state machines, composition patterns, hydration safety, and React 19 APIs. Use when components grow beyond 300 lines, accumulate boolean props, mix unrelated concerns, or need multi-step flow management.

Install this skill

git submodule add https://github.com/modh-labs/playbook.git .playbook

One submodule installs all 17 skills. Reference .playbook/skills/react-architecture/SKILL.md from your AGENTS.md.


React Architecture

1. Decomposition Signals

When a component grows beyond 500 lines, it is an architecture problem, not a formatting problem. Decompose by extracting concerns into custom hooks and JSX blocks into components. The parent becomes thin orchestration: compose hooks, wire callbacks, render.

SignalThresholdAction
File length>300 lines: review. >500 lines: mandatory splitExtract hooks + components
useState count>15 declarationsGroup related state into hooks
useEffect count>4 hooksEach effect should own one concern
Multi-concern useEffectOne effect touching session + timezone + metadataSplit into focused hooks
Dead stateSetter never called OR value never readDelete
Derivable stateshowModal when !!modalData worksReplace useState with const
Same computation 3+ timesderiveRequired(config) called 3xCompute once at top
>5 pure pass-through propsProps forwarded without transformationPush to context or restructure
Boolean prop proliferation3+ boolean props controlling variantsUse explicit variant components

2. Hydration Safety Rules

Iron rule: No browser APIs in useState initializers.

// BAD - hydration mismatch (SSR returns null, client returns value)
const [sessionId] = useState(() => {
  if (typeof window === "undefined") return null;
  return `client-${Date.now()}`;
});

// GOOD - deterministic init, populate on mount
const [sessionId, setSessionId] = useState<string | null>(null);
useEffect(() => {
  setSessionId(`client-${Date.now()}`);
}, []);

Browser-only APIs that MUST be in useEffect, never useState:

CategoryAPIs
Time/randomDate.now(), Math.random(), crypto.randomUUID()
Windowwindow.innerWidth, window.location, window.matchMedia()
Documentdocument.cookie, document.referrer, document.title
Navigatornavigator.userAgent, navigator.language, navigator.onLine
StoragelocalStorage, sessionStorage
IntlIntl.DateTimeFormat().resolvedOptions().timeZone
Media querieswindow.matchMedia("(prefers-color-scheme: dark)")

Safe patterns for dynamic initialization:

// Pattern 1: null init + useEffect
const [timezone, setTimezone] = useState<string | null>(null);
useEffect(() => {
  setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);

// Pattern 2: useSyncExternalStore for subscriptions
const isOnline = useSyncExternalStore(
  (cb) => { window.addEventListener("online", cb); window.addEventListener("offline", cb); return () => { window.removeEventListener("online", cb); window.removeEventListener("offline", cb); }; },
  () => navigator.onLine,
  () => true // server snapshot
);

See references/hydration-safety.md for the complete reference.


3. Hook Extraction Pattern

1. Identify the concern (related state + effects + callbacks)
2. Name it: use{Concern} (useAvailability, useSelection)
3. Move ALL related state + effects + callbacks into the hook
4. Return only what the parent component needs (minimal interface)
5. Parent becomes orchestration: compose hooks, wire callbacks, render

Good hook boundaries:

HookOwnsReturns
useAvailabilityraw slots, loading, error, fetch effect, filtering{ slots, isLoading, error }
useSessionTrackingsessionId, visitorId, tracking effects{ sessionId, visitorId }
useTimezoneselected timezone, detection logic{ timezone, setTimezone }
useDebouncedFormSaveform data, refs, timer, field handler{ formData, handleFieldChange, flush }
useMultiStepFormcurrent step, validation, navigation{ step, canAdvance, next, back }

Bad hook boundaries (naming reveals the problem):

NameProblem
useFormStateToo broad -- which form? What state?
useEffectsGrouping by React concept, not business concern
useMiscNo clear concern
useHelpersUtility bag -- split by what each helper does
useDataEvery hook uses data; name the domain

4. State Machine Pattern

Replace boolean flag soup with an explicit phase type using discriminated unions.

// BAD - ad-hoc booleans create impossible states
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bug: isLoading && isSubmitting can both be true

// GOOD - impossible states are impossible
type Phase =
  | { type: "loading" }
  | { type: "idle" }
  | { type: "submitting" }
  | { type: "confirming" }
  | { type: "success"; resultId: string }
  | { type: "error"; message: string };

const [phase, setPhase] = useState<Phase>({ type: "loading" });

When to use state machines:

ScenarioUse state machine?
Multi-step wizard (3+ steps)Yes
Form with submit/success/errorYes
Simple toggle (open/closed)No -- boolean is fine
Loading a single resourceNo -- isLoading + data is fine
Async flow with confirmation stepYes
Component with 3+ boolean flags for phasesYes -- consolidate

Transition helper pattern:

function transition(current: Phase, event: PhaseEvent): Phase {
  switch (current.type) {
    case "idle":
      if (event === "submit") return { type: "submitting" };
      break;
    case "submitting":
      if (event === "success") return { type: "confirming" };
      if (event === "error") return { type: "error", message: "Failed" };
      break;
    case "confirming":
      if (event === "confirm") return { type: "success", resultId: "..." };
      if (event === "cancel") return { type: "idle" };
      break;
  }
  return current; // Invalid transitions are no-ops
}

5. Composition Patterns

5.1 Compound Components

Structure complex components as a provider + subcomponents. Each subcomponent reads shared state from context. Consumers compose only the pieces they need.

const EditorContext = createContext<EditorContextValue | null>(null);

function EditorProvider({ children, state, actions }: ProviderProps) {
  return (
    <EditorContext value={{ state, actions }}>
      {children}
    </EditorContext>
  );
}

const Editor = {
  Provider: EditorProvider,
  Frame: EditorFrame,
  Input: EditorInput,
  Toolbar: EditorToolbar,
  Submit: EditorSubmit,
};

// Usage: compose what you need
<Editor.Provider state={state} actions={actions}>
  <Editor.Frame>
    <Editor.Input />
    <Editor.Toolbar />
    <Editor.Submit />
  </Editor.Frame>
</Editor.Provider>

5.2 Avoid Boolean Prop Proliferation

Each boolean prop doubles possible states. 4 booleans = 16 combinations, most invalid.

// BAD - exponential complexity
<Editor isThread isEditing={false} showToolbar showEmojis={false} />

// GOOD - explicit variants
<ThreadEditor channelId="abc" />
<EditMessageEditor messageId="xyz" />
<InlineEditor />

5.3 Children Over Render Props

Use children for composition. Use render props only when the parent needs to provide data back to the child.

// BAD - render props for static structure
<Editor renderHeader={() => <Header />} renderFooter={() => <Footer />} />

// GOOD - children compose naturally
<Editor.Frame>
  <Header />
  <Editor.Input />
  <Footer />
</Editor.Frame>

// OK - render props when parent provides data
<List data={items} renderItem={({ item }) => <Item data={item} />} />

5.4 Context Interface Pattern (Dependency Injection)

Define a generic { state, actions, meta } interface. Different providers implement it; same UI consumes it.

interface EditorState { content: string; isSubmitting: boolean }
interface EditorActions { update: (s: EditorState) => void; submit: () => void }
interface EditorContextValue { state: EditorState; actions: EditorActions }

// Provider A: local state
function InlineProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState(initial);
  return <EditorContext value={{ state, actions: { update: setState, submit: inlineSubmit } }}>{children}</EditorContext>;
}

// Provider B: global synced state
function ChannelProvider({ channelId, children }: Props) {
  const { state, update, submit } = useChannelSync(channelId);
  return <EditorContext value={{ state, actions: { update, submit } }}>{children}</EditorContext>;
}

// Same UI works with both providers
<InlineProvider><Editor.Frame><Editor.Input /><Editor.Submit /></Editor.Frame></InlineProvider>
<ChannelProvider channelId="abc"><Editor.Frame><Editor.Input /><Editor.Submit /></Editor.Frame></ChannelProvider>

5.5 Lift State into Providers

Move state out of components into providers so siblings can access it without prop drilling, effect syncing, or ref hacks.

// BAD - state trapped inside, siblings can't access it
function EditorDialog() {
  return (
    <Dialog>
      <InlineEditor />            {/* state lives here */}
      <Preview />                 {/* needs editor state - how? */}
      <SubmitButton />            {/* needs submit action - how? */}
    </Dialog>
  );
}

// GOOD - provider lifts state, siblings access via context
function EditorDialog() {
  return (
    <EditorProvider>
      <Dialog>
        <InlineEditor />
        <Preview />              {/* reads state via use(EditorContext) */}
        <SubmitButton />         {/* calls actions.submit via use(EditorContext) */}
      </Dialog>
    </EditorProvider>
  );
}

Key insight: components that need shared state do not have to be visually nested. They just need to be within the same provider.

See references/composition-patterns.md for full examples.


6. React 19 Patterns

6.1 No forwardRef

In React 19, ref is a regular prop. Drop the forwardRef wrapper.

// BAD - React 18 pattern, unnecessary in React 19
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
  return <input ref={ref} {...props} />;
});

// GOOD - React 19: ref is just a prop
function Input({ ref, ...props }: Props & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

6.2 use() Instead of useContext()

// BAD - React 18 pattern
const value = useContext(MyContext);

// GOOD - React 19: use() can be called conditionally
const value = use(MyContext);

6.3 useOptimistic for Instant Feedback

Update UI immediately, reconcile when the server responds.

function StatusCard({ item }: { item: Item }) {
  const [optimistic, setOptimistic] = useOptimistic(item);

  async function handleStatusChange(newStatus: string) {
    setOptimistic({ ...item, status: newStatus }); // instant
    await updateStatus(item.id, newStatus);         // server catches up
  }

  return (
    <Card>
      <Badge>{optimistic.status}</Badge>
      <StatusSelector onChange={handleStatusChange} />
    </Card>
  );
}
ActionUse optimistic?
Toggle, status changeYes
Form submissionYes (with validation)
DeleteShow confirmation first, then optimistic
Complex multi-stepNo -- use loading state

6.4 useTransition for Non-Blocking Mutations

Keep the UI responsive during server mutations.

function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  const [isPending, startTransition] = useTransition();

  return (
    <Button
      onClick={() => startTransition(onSave)}
      disabled={isPending}
    >
      {isPending ? "Saving..." : "Save"}
    </Button>
  );
}

7. Component Extraction Signals + Size Targets

Extraction Signals

SignalExtract as
JSX block >50 lines with no parent state dependencyPresentational component
Loading skeleton{Name}Skeleton.tsx (zero props)
Error/empty state{Name}ErrorState.tsx
Modal + its state managementuse{Name}Modal hook + {Name}Modal.tsx
Repeated JSX pattern across 3+ placesShared presentational component
Form section with own validation{Section}Fields.tsx + use{Section}Validation

Size Targets After Decomposition

FileTargetHard limit
Parent orchestrator200-400 lines500 lines
Custom hook50-150 lines200 lines
Presentational component20-80 lines120 lines
Skeleton / error state10-40 lines60 lines

8. Common Mistakes

MistakeFix
useState for immutable propUse const x = prop
useState for derivable valueUse const x = !!otherState or useMemo
Dead state (setter prefixed with _)Delete entirely
useEffect with 8+ dependenciesBreak into focused hooks by concern
.length as effect dep causing re-firesUse a ref for context, not a dependency
Same function called 3x with same argsCompute once, store in const
console.log in productionDelete -- use structured logger
forwardRef in React 19 projectRemove wrapper, use ref as prop
useContext() in React 19 projectReplace with use()
Boolean props controlling variantsCreate explicit variant components
State trapped in component, sibling needs itLift to provider
renderX props for static structureUse children composition

9. Quick Audit Checklist

Before shipping any component >300 lines:

  • No browser APIs in useState initializers
  • No dead state (unused setters or unread values)
  • No derivable state stored in useState
  • Each useEffect owns exactly one concern
  • No effect dependency that causes unintended re-fires
  • Same value not computed more than once
  • Loading/error JSX extracted to separate components
  • File under 500 lines
  • No boolean prop proliferation (3+ booleans controlling variants)
  • Multi-step flows use discriminated union, not boolean soup
  • Complex shared state lifted to provider, not trapped in component
  • React 19: no forwardRef, use use() not useContext()

Related Skills

layers

Next.js Data & Mutation Patterns

Implement data fetching, server actions, and performance patterns for Next.js App Router. Use when fetching data in pages, writing mutations, adding loading states, Suspense boundaries, prefetching, or discussing data flow. Enforces Server Components, repository usage, structured returns, and progressive loading.

React / Next.js
folder-tree

Route Colocation

Guide code organization, folder structure, and route architecture following colocation-first patterns. Use when creating routes, deciding file placement, organizing imports, or structuring documentation. Enforces the 3+ routes sharing threshold, actions folder pattern, and documentation placement rules.

React / Next.js
square

shadcn/ui Component Patterns

Build UI components following shadcn/ui patterns. Use when creating components, styling, theming, building detail views, sheets, dialogs, or forms. Enforces shadcn components, CSS variables, variant usage, detail view architecture, and component colocation.

React / Next.js
←Back to Agent Skills