I have shipped a lot of React in production, and the same pain points keep returning:
- Writable computed state is unnatural.
- Custom hooks are hard to extend.
- 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} />
}
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
}
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
}
It improves ergonomics, but:
- it still depends on
useEffectsynchronization; - 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
}
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
}
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),
)
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
}),
)
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'),
)
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.tspackages/core/src/web/reatomMediaQuery.ts-
withLocalStoragefrom 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')
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
}
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 }))
Why this shape is often easier to maintain:
- early returns naturally cut off unnecessary subscriptions/work;
- no
enabledparameter 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)