Skip to main content

@preact/signals vs React useState vs Jotai: Fine-Grained Reactivity in React (2026)

·PkgPulse Team

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

PackageWeekly DownloadsApproachRe-render scopeReact overhead
React useStateBuilt-inHookAll subscribersFull
jotai~800KAtomicAtom subscribersPartial
@preact/signals-react~600KSignalsNone (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

ScenariouseStateJotaiSignals
Component subscribes to 1 valueFull re-renderOnly this atomDOM update only
1000 items in list, filter by textFull re-render per keystrokeFiltered atom re-renders listDirect DOM update
Live data at 10 updates/sec10 re-renders/sec10 partial re-renders/secNo re-renders
Sibling component isolationNo (needs memo)✅ Yes✅ Yes
BoilerplateMinimalMediumMedium
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.

Compare state management and React packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.