@preact/signals vs React useState vs Jotai: Fine-Grained Reactivity in React (2026)
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
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.