Back to Blogs
July 20, 20263 min read

React Patterns Every Engineer Should Know

A practical guide to React patterns that make your components easier to test, reuse, and maintain — with real examples from production code.

Why Patterns Matter

React is unopinionated by design. That freedom is powerful, but it also means teams can drift into inconsistent, hard-to-maintain code. Patterns are shared vocabulary — they give teams a way to name solutions and make decisions faster.

These are the patterns I keep reaching for.

1. Compound Components

When a component has several related parts that need to share state, compound components keep the API clean.

function Tabs({ children, defaultTab }) {
  const [active, setActive] = useState(defaultTab)
  return (
    <TabContext.Provider value={{ active, setActive }}>
      {children}
    </TabContext.Provider>
  )
}

Tabs.List = function TabList({ children }) {
  return <div role="tablist">{children}</div>
}

Tabs.Tab = function Tab({ id, children }) {
  const { active, setActive } = useContext(TabContext)
  return (
    <button
      role="tab"
      aria-selected={active === id}
      onClick={() => setActive(id)}
    >
      {children}
    </button>
  )
}

Tabs.Panel = function TabPanel({ id, children }) {
  const { active } = useContext(TabContext)
  return active === id ? <div role="tabpanel">{children}</div> : null
}

Consumers get a declarative, flexible API without prop drilling:

<Tabs defaultTab="overview">
  <Tabs.List>
    <Tabs.Tab id="overview">Overview</Tabs.Tab>
    <Tabs.Tab id="details">Details</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel id="overview"><Overview /></Tabs.Panel>
  <Tabs.Panel id="details"><Details /></Tabs.Panel>
</Tabs>

2. Render Props (Still Useful)

Hooks replaced many render-prop patterns, but render props remain valuable when you need to share JSX structure while delegating rendering control.

function DataTable<T>({
  data,
  renderRow,
}: {
  data: T[]
  renderRow: (item: T, index: number) => React.ReactNode
}) {
  return (
    <table>
      <tbody>
        {data.map((item, i) => (
          <tr key={i}>{renderRow(item, i)}</tr>
        ))}
      </tbody>
    </table>
  )
}

The generic type parameter lets callers keep full type safety without knowing anything about the table's internals.

3. Custom Hooks as Encapsulation Units

Move stateful logic out of components and into hooks. This isn't just about reuse — it's about separation of concerns.

function useFormField(initialValue = '') {
  const [value, setValue] = useState(initialValue)
  const [touched, setTouched] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value)
    setError(null)
  }, [])

  const onBlur = useCallback(() => {
    setTouched(true)
  }, [])

  return { value, touched, error, setError, onChange, onBlur }
}

Your form component becomes a pure layout concern. Business validation lives in the hook or a validator it calls.

4. State Machines for Complex UI

When component state has multiple exclusive states that transition through defined rules, reach for a state machine. useReducer with an explicit state enum prevents impossible states.

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

type Action =
  | { type: 'FETCH' }
  | { type: 'RESOLVE'; data: User }
  | { type: 'REJECT'; message: string }
  | { type: 'RESET' }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH': return { status: 'loading' }
    case 'RESOLVE': return { status: 'success', data: action.data }
    case 'REJECT': return { status: 'error', message: action.message }
    case 'RESET': return { status: 'idle' }
    default: return state
  }
}

With a discriminated union, TypeScript narrows the type in each switch branch. No more data !== null && !loading && !error guard chains.

5. Controlled vs Uncontrolled — Pick One

Mixing controlled and uncontrolled behaviour in the same component is the source of many React bugs. If a parent needs to read or reset the value, make it controlled. If not, use an uncontrolled ref. Don't half-do both.

// Controlled — parent owns the value
function ControlledInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />
}

// Uncontrolled — internal ref, exposed via forwarding
const UncontrolledInput = React.forwardRef<HTMLInputElement, Props>(
  (props, ref) => <input ref={ref} {...props} />
)

The Meta Pattern

Every pattern above follows the same principle: push state up only as far as it needs to go, and push rendering down as far as it can go. When a component is too large or too coupled, it's usually violating one of these two directions.

These patterns aren't rules — they're tools. The best engineers know when to reach for each one, and when to write something simpler.

Written by

Zikri Akmal Santoso

Software Engineer

More Articles