DEV Community

Cover image for When React Hooks Start Feeling Heavy
Artyom
Artyom

Posted on

When React Hooks Start Feeling Heavy

I have shipped a lot of React in production, and the same pain points keep returning:

  1. Writable computed state is unnatural.
  2. Custom hooks are hard to extend.
  3. Hook order and effect-based sync add performance and reliability costs.

This is not about "React bad". React is excellent at rendering and ecosystem compatibility. The point is practical: when state derivation gets complex, many teams end up with key reset tricks, sync setState, useEffect syncing, and extra glue variables.

Problem 1: Writable Computed State Feels Unnatural

The classic case is reset-on-parent-change. For example: reset pagination when search changes, or reset one draft field when fresh backend data arrives.

The key reset trick is coarse

function SearchPage({ query }: { query: string }) {
  return <SearchPanel key={query} query={query} />
}
Enter fullscreen mode Exit fullscreen mode

This resets everything in SearchPanel. It does not help when a component has several local states and you need to drop only one, for example: reset page but keep filters, sort, viewMode, or an unsaved draft input.

The common React fallback is sync logic

function SearchPanel({ query }: { query: string }) {
  const [page, setPage] = useState(1)
  const [filters, setFilters] = useState({ onlyActive: false })

  useEffect(() => {
    setPage(1)
  }, [query])

  return null
}
Enter fullscreen mode Exit fullscreen mode

This works, but introduces an extra render cycle and opens room for visual glitches in real UIs.

A custom useStateComputed can help, but still has limits

function useStateComputed<State>(
  initState: State,
  compute: (state: State) => State,
  deps: ReadonlyArray<unknown>,
) {
  const [state, setState] = useState(initState)
  const computedRef = useRef({ value, deps: [] })
  const { value, deps: prevDeps } = computedRef.current

  if (
    prevDeps.length !== deps.length ||
    prevDeps.some((dep, index) => !Object.is(dep, deps[index]))
  ) {
    computedRef.current = { value: compute(state), deps }
  }

  return computedRef.current.value
}
Enter fullscreen mode Exit fullscreen mode

It improves ergonomics, but:

  • it still depends on useEffect synchronization;
  • it is still local and isolated from extension composition;
  • ref-based sync can be fragile in StrictMode and concurrent rendering;
  • it does not solve "add persistence/validation/logger to existing state atomically".

Problem 2: Hooks Are Not Naturally Extensible

Imagine a hook from internal or npm code:

function useSomeData(url: string) {
  const [data, setData] = useState<null | { value: string }>(null)

  useEffect(() => {
    let active = true
    fetch(url)
      .then((r) => r.json())
      .then((next) => {
        if (active) setData(next)
      })

    return () => {
      active = false
    }
  }, [url])

  return data
}
Enter fullscreen mode Exit fullscreen mode

Now you want to add:

  • persistence before state initialization;
  • schema validation and transformation on updates;
  • conditional synchronization behavior.

In React, if the author did not design an extension API for this, you usually duplicate state and synchronize through effects and flags.

function useSomeDataWithPersist(url: string) {
  const remote = useSomeData(url)
  const [persisted, setPersisted] = useState<null | { value: string }>(null)
  const [ready, setReady] = useState(false)

  useEffect(() => {
    const raw = localStorage.getItem('someData')
    if (raw) setPersisted(JSON.parse(raw))
    setReady(true)
  }, [])

  useEffect(() => {
    if (!ready || !remote) return
    setPersisted(remote)
    localStorage.setItem('someData', JSON.stringify(remote))
  }, [ready, remote])

  return ready ? persisted : null
}
Enter fullscreen mode Exit fullscreen mode

This is boilerplate-heavy and hard to keep consistent.

Problem 3: Hook Order, Conditional Logic, and Effect Cost

This is where React usually needs more discipline and conventions. We can absolutely make it work, but in complex flows we often pay with:

  • extra unions/default values for conditional hook params;
  • all hooks still being created by rule-of-hooks order;
  • larger "enabled" condition surfaces, which are easy to overcomplicate;
  • additional effect synchronization when data flow is not strictly linear.

The real production useBalanceState case is shown at the end of this article with a direct Reatom comparison.

Reatom: Writable Computed State Without Effects

In Reatom, mutable atoms can have dependent writable computation with withComputed.

import { atom, withComputed } from '@reatom/core'

type Tab = { id: string }

export const tabs = atom<Array<Tab>>([], 'tabs')

export const currentTab = atom<Tab | null>(null, 'currentTab').extend(
  withComputed((state) => tabs().at(-1) ?? state),
)
Enter fullscreen mode Exit fullscreen mode

A practical pagination reset:

import { atom, withComputed, withSearchParams } from '@reatom/core'

const search = atom('', 'search').extend(withSearchParams('search'))

const page = atom(1, 'page').extend(
  withSearchParams('page'),
  withComputed((state) => {
    search()
    return 1
  }),
)
Enter fullscreen mode Exit fullscreen mode

No extra sync effects. No duplicated state machine.

Reatom Extensibility: Compose Behavior Instead of Forking Hooks

Reatom atoms are designed for composition through extensions.

import {
  reatomEnum,
  withComputed,
  withLocalStorage,
  reatomMediaQuery,
} from '@reatom/core'

const isDarkModeMedia = reatomMediaQuery('(prefers-color-scheme: dark)')

const theme = reatomEnum(['system', 'light', 'dark'], 'theme').extend(
  withComputed((state) => {
    if (state === 'system') return isDarkModeMedia() ? 'dark' : 'light'
    return state
  }),
  withLocalStorage('theme'),
)
Enter fullscreen mode Exit fullscreen mode

This pattern solves the common "theme + persistence + system preference" case without custom hook duplication.

The same extension model works for adapters from library code or teammates. You can attach persistence, mapping, validation, analytics, and middleware behavior to existing atoms.

Core pieces used in this article are implemented in:

  • packages/core/src/primitives/reatomEnum.ts
  • packages/core/src/web/reatomMediaQuery.ts
  • withLocalStorage from Reatom persist adapters

Conditional Reactivity and Better Runtime Efficiency

With reatomComponent, you read only what you need, when you need it.

import { reatomComponent } from '@reatom/react'

const Panel = reatomComponent(() => {
  if (!isFeatureEnabled()) return null
  return <div>{expensiveState()}</div>
}, 'Panel')
Enter fullscreen mode Exit fullscreen mode

When a branch does not read a state, there is no subscription to that state in that branch.

Also, reatomMediaQuery subscribes to window.matchMedia events only when someone actually uses it, which avoids waste in inactive features and scales better in large apps.

Debugging: Tracing Through Effects and Update Chains

Reatom has first-class tracing and logging for update causes, including chains through effects and async boundaries. In practice, this means less guessing and less "where did this update come from?" time.

In React-heavy flows, rerender-driven debugging is often noisy: you get many extra renders in typical apps, so logs become harder to read and signal-to-noise drops quickly. Reatom devtools/logs focus on cause chains and state transitions, which keeps the timeline clearer.

Quick Comparison

Case React hooks typical solution Reatom solution
Reset one dependent state key, useEffect, sync setState withComputed on the target atom
Add persistence to existing logic duplicate state + effects add withLocalStorage extension
Add derived rules to existing state new custom hook or refactor source compose withComputed and other extensions
Conditional subscriptions constrained by hook order subscribe only by actual reads
Trace update causes fragmented logs and effects built-in cause-aware logs and tooling

Extra: Real Production Case (useBalanceState)

This case is from a real production codebase.
You will see the original React hooks implementation first, then a Reatom-oriented variant with the same business intent. The goal is not to shame the React code. The goal is to show how the same production rules can be modeled with fewer coordination points and clearer data flow.

React hooks version

function useBalanceState() {
  const user = useUser()
  const { id, startDate, isCanceled, isPrepaid } = useAgreement() ?? {}

  const isNewAgreement =
    !startDate ||
    differenceInDays(endOfToday(), startOfDay(new Date(startDate))) <
      NEW_AGREEMENT_DAYS

  const hasAccess = Boolean(user?.rules.includes(Rules.ViewBalance))
  const isBalanceAlertPossible =
    !isCanceled && isPrepaid && isNewAgreement && hasAccess && id

  const agreementBalanceParams = isBalanceAlertPossible
    ? { enabled: true, agreementId: id }
    : { enabled: false, agreementId: '' }

  const { balanceData, bonusData } = useAgreementBalance(agreementBalanceParams)

  const dailyConsumptionParams = isBalanceAlertPossible
    ? {
        agreementId: id,
        startDate: startOfYesterday().toISOString(),
        endDate: endOfYesterday().toISOString(),
      }
    : { agreementId: '' }

  const { data: dailyConsumption } = useDailyProjectConsumptionQuery(
    dailyConsumptionParams,
    { options: { enabled: Boolean(isBalanceAlertPossible) } },
  )

  if (isCanceled) return BalanceStatus.ResourcesStopped
  if (!isBalanceAlertPossible || !balanceData || !bonusData)
    return BalanceStatus.None

  const yesterdayConsumption = dailyConsumption?.consumptions?.[0]?.amount ?? 0
  if (
    balanceData.balance < 0 ||
    yesterdayConsumption <= balanceData.balance + bonusData.bonuses
  ) {
    return BalanceStatus.LowBalance
  }

  return BalanceStatus.None
}
Enter fullscreen mode Exit fullscreen mode

Reatom-oriented variant

import { atom, computed, withAsyncData, wrap } from '@reatom/core'

const userAtom = atom<User | null>(null, 'user')
const agreementAtom = atom<Agreement | null>(null, 'agreement')

const balanceState = computed(async () => {
  const agreement = agreementAtom()
  if (!agreement) return BalanceStatus.None

  const { id, startDate, isCanceled, isPrepaid } = agreement
  if (isCanceled) return BalanceStatus.ResourcesStopped

  const isNewAgreement =
    !startDate ||
    differenceInDays(endOfToday(), startOfDay(new Date(startDate))) <
      NEW_AGREEMENT_DAYS

  const hasAccess = Boolean(userAtom()?.rules.includes(Rules.ViewBalance))
  const isBalanceAlertPossible =
    Boolean(id) && isPrepaid && isNewAgreement && hasAccess

  if (!isBalanceAlertPossible) return BalanceStatus.None

  const [{ balanceData, bonusData }, dailyConsumption] = await wrap(
    Promise.all([
      getAgreementBalance({ agreementId: id }),
      getDailyProjectConsumption({
        agreementId: id,
        startDate: startOfYesterday().toISOString(),
        endDate: endOfYesterday().toISOString(),
      }),
    ]),
  )

  if (!balanceData || !bonusData) return BalanceStatus.None

  const yesterdayConsumption = dailyConsumption?.consumptions?.[0]?.amount ?? 0
  if (
    balanceData.balance < 0 ||
    yesterdayConsumption <= balanceData.balance + bonusData.bonuses
  ) {
    return BalanceStatus.LowBalance
  }

  return BalanceStatus.None
}, 'balanceState').extend(withAsyncData({ initState: BalanceStatus.None }))
Enter fullscreen mode Exit fullscreen mode

Why this shape is often easier to maintain:

  • early returns naturally cut off unnecessary subscriptions/work;
  • no enabled parameter choreography across multiple hooks;
  • one place for business rules and data dependency flow;
  • async chain is visible and traceable in Reatom logs/devtools.

Final Take

React hooks are a strong default for many UIs. As state orchestration grows, writable computed state, extension layering, and effect coordination can become expensive in both code and runtime behavior.

Reatom gives a cleaner model:

  • writable computed state through withComputed;
  • open extension system for composition;
  • conditional reactivity without hook-order gymnastics;
  • robust tracing for real production debugging.

If your team keeps writing "state + effect + sync + flags" patterns, that is usually a signal that the model is fighting your domain.

Top comments (0)