TL;DR
React useState is the baseline — built-in, simple, but causes all components subscribed to a state value to re-render on every change. Jotai improves on this with atomic state that only re-renders components that subscribe to a specific atom. @preact/signals takes the most aggressive approach — signals bypass React's re-render cycle entirely and update the DOM directly, making them the fastest option for high-frequency state updates. For most React apps, useState is fine. For forms, live data, or animations where re-renders matter, signals or Jotai provide meaningful gains.
Key Takeaways
- React useState: Zero overhead, works everywhere, but triggers re-renders for all consumers
- Jotai: ~800K weekly downloads — atomic state, only re-renders subscribers to changed atoms
- @preact/signals: ~600K weekly downloads — bypasses React's virtual DOM, direct DOM updates
- Signals are the fastest for high-frequency updates (live data, form fields, animations)
- Jotai is the practical middle ground — better perf than useState, works with React's model
- useState is correct for 90% of cases — only reach for signals/atoms when you have a perf problem
Download Trends
| Package | Weekly Downloads | Approach | Re-render scope | React overhead |
|---|---|---|---|---|
React useState | Built-in | Hook | All subscribers | Full |
jotai | ~800K | Atomic | Atom subscribers | Partial |
@preact/signals-react | ~600K | Signals | None (direct DOM) | Bypassed |
The Re-render Problem
// useState: Every component that uses `downloads` re-renders on change
function App() {
const [downloads, setDownloads] = useState(0)
// Live data: updating downloads every 100ms causes:
// - DownloadCounter to re-render ✅ (expected)
// - Header to re-render ❌ (doesn't use downloads)
// - Sidebar to re-render ❌ (doesn't use downloads)
return (
<>
<Header /> {/* Re-renders unnecessarily */}
<DownloadCounter downloads={downloads} /> {/* Needs this */}
<Sidebar /> {/* Re-renders unnecessarily */}
</>
)
}
The solutions each library takes:
- React.memo / useMemo — prevent renders by memoizing (but adds boilerplate)
- Jotai atoms — subscribe only to the exact atom you need
- Signals — bypass React entirely for reads, update DOM directly
React useState
The baseline — use when performance isn't a concern (which is most of the time):
import { useState, useCallback, useMemo } from "react"
// Simple counter:
function DownloadCounter() {
const [count, setCount] = useState(0)
const increment = useCallback(() => setCount((n) => n + 1), [])
return (
<div>
<p>Downloads: {count}</p>
<button onClick={increment}>Track Download</button>
</div>
)
}
// Form state (causes re-render on every keystroke):
function SearchForm() {
const [query, setQuery] = useState("")
const [results, setResults] = useState<Package[]>([])
// Problem: every keystroke re-renders this entire component + children
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search packages..."
/>
<ResultsList results={results} />
</div>
)
}
When useState causes problems
// High-frequency updates — each update triggers full re-render cycle:
function LiveDownloadTracker({ packageName }: { packageName: string }) {
const [downloads, setDownloads] = useState(0)
const [velocity, setVelocity] = useState(0)
const [trend, setTrend] = useState<"up" | "down" | "stable">("stable")
useEffect(() => {
const ws = new WebSocket(`wss://api.pkgpulse.com/live/${packageName}`)
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
// Each update causes 3 separate re-renders (or 1 with batching in React 18+):
setDownloads(data.downloads)
setVelocity(data.velocity)
setTrend(data.trend)
}
return () => ws.close()
}, [packageName])
return (
<div>
<h2>{downloads.toLocaleString()} downloads</h2>
<p>Velocity: {velocity}/hr</p>
<p>Trend: {trend}</p>
</div>
)
}
// With React 18 automatic batching: 1 re-render per message
// Without batching (React 17): 3 re-renders per message
Jotai
Jotai takes an atomic approach — each atom is an independent unit of state, and components only re-render when the specific atoms they subscribe to change.
Basic atoms
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
// Define atoms (global state outside of components):
const downloadsAtom = atom(0)
const velocityAtom = atom(0)
const trendAtom = atom<"up" | "down" | "stable">("stable")
// Derived atom (like useMemo but global):
const statsAtom = atom((get) => ({
downloads: get(downloadsAtom),
velocity: get(velocityAtom),
trend: get(trendAtom),
formattedDownloads: get(downloadsAtom).toLocaleString(),
}))
// Read + write:
function DownloadDisplay() {
const downloads = useAtomValue(downloadsAtom) // Only re-renders when downloadsAtom changes
return <h2>{downloads.toLocaleString()}</h2>
}
// Write-only (no re-render when atom changes):
function DownloadUpdater({ packageName }: { packageName: string }) {
const setDownloads = useSetAtom(downloadsAtom)
const setVelocity = useSetAtom(velocityAtom)
const setTrend = useSetAtom(trendAtom)
useEffect(() => {
const ws = new WebSocket(`wss://api.pkgpulse.com/live/${packageName}`)
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
setDownloads(data.downloads) // Only DownloadDisplay re-renders
setVelocity(data.velocity) // Only VelocityDisplay re-renders
setTrend(data.trend) // Only TrendDisplay re-renders
}
return () => ws.close()
}, [packageName])
return null // This component never re-renders on state changes
}
Atom families (parameterized atoms)
import { atomFamily } from "jotai/utils"
// Create atoms per-package (like useState but per instance):
const packageAtomFamily = atomFamily((name: string) =>
atom({
name,
downloads: 0,
loading: false,
error: null as Error | null,
})
)
// Use in component — each package has independent state:
function PackageCard({ name }: { name: string }) {
const [pkg, setPackage] = useAtom(packageAtomFamily(name))
useEffect(() => {
setPackage((prev) => ({ ...prev, loading: true }))
fetchPackageData(name).then((data) => {
setPackage({ name, ...data, loading: false, error: null })
})
}, [name])
if (pkg.loading) return <Skeleton />
return <Card data={pkg} />
}
Async atoms
import { atom } from "jotai"
// Async atom — automatically handles loading/error states:
const packageDataAtom = atom(async (get) => {
const name = get(selectedPackageAtom)
const response = await fetch(`/api/packages/${name}`)
return response.json() as Promise<PackageData>
})
function PackageDetails() {
// Suspense-compatible — wrap with <Suspense> for loading state:
const packageData = useAtomValue(packageDataAtom)
return <div>{packageData.name}: {packageData.weeklyDownloads}</div>
}
// Usage:
<Suspense fallback={<Skeleton />}>
<PackageDetails />
</Suspense>
@preact/signals
@preact/signals-react brings Preact's signal primitive to React — signals bypass the virtual DOM and update the DOM directly.
How signals differ
import { signal, computed, effect } from "@preact/signals-react"
// Signals are reactive values defined outside components:
const downloads = signal(0)
const velocity = signal(0)
const trend = signal<"up" | "down" | "stable">("stable")
// Computed signal (auto-updates when dependencies change):
const formattedDownloads = computed(() => downloads.value.toLocaleString())
const healthBadge = computed(() => {
if (velocity.value > 1000) return "trending"
if (velocity.value < -100) return "declining"
return "stable"
})
// Effect (like useEffect but reactive to signal changes):
const cleanup = effect(() => {
console.log(`Downloads: ${downloads.value} (${healthBadge.value})`)
// Runs whenever downloads or velocity changes
})
// Update signals anywhere — components using them update automatically:
function updateFromWebSocket(data: LiveData) {
downloads.value = data.downloads // DOM updates directly — no re-render
velocity.value = data.velocity
trend.value = data.trend
}
Signals in React components
import { signal, computed } from "@preact/signals-react"
import { useSignals } from "@preact/signals-react/runtime"
// Signals defined at module level (shared globally):
const downloads = signal(0)
const query = signal("")
const filteredPackages = computed(() =>
allPackages.filter((p) => p.name.includes(query.value))
)
// In components: useSignals() enables signal reactivity
function SearchBox() {
useSignals() // Required for signals to work in React components
return (
<input
value={query.value}
onInput={(e) => {
query.value = (e.target as HTMLInputElement).value
// No re-render of this component — DOM updates directly
}}
/>
)
}
function PackageList() {
useSignals()
return (
<ul>
{filteredPackages.value.map((pkg) => (
<li key={pkg.name}>{pkg.name}</li>
))}
</ul>
)
}
// SearchBox updates query.value → filteredPackages recomputes → PackageList DOM updates
// The component tree does NOT re-render — only the changed DOM nodes update
Signals for form fields (zero re-renders)
import { signal } from "@preact/signals-react"
import { useSignals } from "@preact/signals-react/runtime"
// Each field is a signal — typing doesn't trigger React renders:
const packageName = signal("")
const alertThreshold = signal(20)
const alertEmail = signal("")
function CreateAlertForm() {
useSignals()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createAlert({
packageName: packageName.value,
threshold: alertThreshold.value,
email: alertEmail.value,
})
}
return (
<form onSubmit={handleSubmit}>
<input
value={packageName.value}
onInput={(e) => { packageName.value = (e.target as HTMLInputElement).value }}
placeholder="Package name"
/>
<input
type="number"
value={alertThreshold.value}
onInput={(e) => { alertThreshold.value = Number((e.target as HTMLInputElement).value) }}
/>
<input
type="email"
value={alertEmail.value}
onInput={(e) => { alertEmail.value = (e.target as HTMLInputElement).value }}
/>
<button type="submit">Create Alert</button>
</form>
)
}
// Typing in any field: zero React re-renders, only DOM value updates
Performance Comparison
| Scenario | useState | Jotai | Signals |
|---|---|---|---|
| Component subscribes to 1 value | Full re-render | Only this atom | DOM update only |
| 1000 items in list, filter by text | Full re-render per keystroke | Filtered atom re-renders list | Direct DOM update |
| Live data at 10 updates/sec | 10 re-renders/sec | 10 partial re-renders/sec | No re-renders |
| Sibling component isolation | No (needs memo) | ✅ Yes | ✅ Yes |
| Boilerplate | Minimal | Medium | Medium |
| React DevTools visibility | ✅ | ✅ | ⚠️ Limited |
| Concurrent Mode safe | ✅ | ✅ | ⚠️ Experimental |
When to Use Each
Use React useState if:
- Standard state that updates a few times per second
- Simple components where performance isn't a concern (90% of use cases)
- You want full React DevTools support and debugging
- SSR/Next.js where signals compatibility is less tested
Choose Jotai if:
- State needs to be shared across components with precision
- You want atomic state without leaving React's model
- You need derived/computed state (like useMemo but global)
- Cross-cutting state (user auth, theme, feature flags) shared across unrelated components
Choose @preact/signals if:
- High-frequency state: form fields, live data feeds, animations
- You want to avoid re-renders entirely for hot paths
- You're building something like a spreadsheet, real-time dashboard, or text editor
- You're already familiar with signal semantics from Solid.js, Vue's reactivity, or Angular signals
TypeScript Integration and Type Safety
All three approaches offer solid TypeScript support, but the ergonomics differ in ways that matter at scale. React useState is fully type-inferred — TypeScript figures out the state type from the initial value, and you rarely need explicit annotations. Jotai's atoms are generic and carry their type through the entire atom graph, including derived atoms, making cross-component type safety reliable without extra annotation overhead. The useAtom, useAtomValue, and useSetAtom hooks all preserve the atom's type, so TypeScript catches type mismatches at the call site. @preact/signals-react requires some care with TypeScript because signal.value reads from outside React's type system — the compiler sees the signal as a reactive wrapper, not the raw value type. The useSignals() hook that must be called in each component adds a runtime requirement that TypeScript cannot enforce, making it easier to introduce bugs by forgetting the call in a new component. For strictly typed shared state, Jotai's explicit atom definitions tend to be more refactor-safe.
Production Considerations and Server-Side Rendering
Each library handles SSR differently, and the differences matter for Next.js applications. React useState is fully SSR-safe — it initializes on the server, hydrates on the client, and participates in React's streaming SSR without any special handling. Jotai provides server-side atom stores via createStore() and the Provider component, enabling per-request state isolation necessary for concurrent server rendering. Without proper isolation, atoms created at module scope would share state across requests in a Node.js server — a critical production bug. @preact/signals-react has the most friction with SSR: signals defined at module scope are inherently shared across requests on the server, and the library's concurrent mode compatibility is still marked experimental as of early 2026. Teams running heavy SSR workloads or targeting React Server Components should carefully test signal behavior before committing to the approach.
Migration Paths and Adoption Strategy
Migrating an existing React codebase toward better state management does not have to be all-or-nothing. The pragmatic approach is to start with the hottest render paths — form-heavy UI, high-frequency live data feeds, or large lists with filtering — and measure before reaching for signals or atoms. A useful heuristic is to look at your React DevTools Profiler: if components are re-rendering without their props changing, that is a signal (no pun intended) that you need fine-grained state. Moving from useState to Jotai is straightforward because Jotai sits inside React's model and does not require changing your rendering approach. Moving from useState to @preact/signals-react is more disruptive because it pulls rendering control away from React entirely, which can conflict with third-party libraries and React DevTools expectations. Teams evaluating signals should prototype on a single isolated feature first rather than adopting them application-wide.
Community Adoption and Ecosystem Context
Jotai occupies an interesting position at around 800K weekly downloads — it is widely trusted but dwarfed by the broader zustand (~3M) and Redux ecosystem. Its atomic model has influenced React's own direction; React's use() hook and the Recoil experiment that predated Jotai both reflect the same insight that atom-level subscriptions reduce unnecessary renders. @preact/signals saw a surge in interest after the Preact team published benchmarks showing near-zero render overhead, but its React integration carries an asterisk because it works against React's scheduler rather than with it. This is not merely a philosophical concern — it means signals can break React's batching guarantees in edge cases involving Suspense boundaries and concurrent features. The safest interpretation in 2026 is to treat signals as a targeted optimization for specific hot paths rather than an architectural foundation.
Performance Nuances Beyond Re-Renders
The re-render count comparison — useState causes full re-renders, Jotai causes partial re-renders, signals cause none — is accurate but incomplete. The deeper performance story involves JavaScript garbage collection, memory pressure, and the V8 JIT compiler's ability to optimize tight render loops. useState's simplicity means V8 can reason about its allocations easily. Jotai's atom subscriptions add a subscription graph that must be maintained in memory and walked on each state change; for applications with hundreds of atoms, this graph traversal can become a bottleneck. Signals avoid this by using a push-based reactivity system that is structurally similar to observables: when a signal's value changes, only the specific DOM nodes or computed signals that depend on it update. In a dashboard rendering 50 live data feeds, the difference between Jotai (50 React re-renders per update cycle) and signals (0 re-renders, 50 direct DOM mutations) can be the difference between a 60fps UI and a janky one. But for a typical CRUD application with 10-20 state values, all three approaches produce imperceptible differences.
Methodology
Download data from npm registry (weekly average, February 2026). Performance comparisons based on community benchmarks and official documentation. Feature comparison based on Jotai v2.x, @preact/signals-react v2.x, and React 19.x.
Compare state management and React packages on PkgPulse →
See also: Preact vs React and React vs Vue, react-scan vs why-did-you-render vs Million Lint 2026.