Home / Articles

Legend State for fine-grained React and React Native updates

Move hot state into Legend State observables so playlist rows and timers re-render only the cells that changed instead of whole React Native subtrees.

Publisher: REACTAPP.TOP5 min read1202 words

This article is published in English.

Sources: Legend State: The Fastest React State Library You’re Probably Not Using

Optimizing React Native lists is a familiar performance story, and libraries such as Legend List push scrolling close to FlashList-class numbers. After enough production audits, though, list virtualization stops being the dominant bottleneck. The quieter cost is often state: where it lives in the tree, and how many components re-render when a single field changes.

Built-in hooks (useState, useContext) are easy to reach for and frequently incompatible with “render less, render smaller.” Many external stores improve the situation. Legend State, now in a 3.0 beta, takes a particularly fine-grained approach—separating state creation from subscription—and was demonstrated in depth at Chain React 2026.

The Problem with useState

The performance thesis behind the library is blunt: the fastest apps shrink how much React and React Native must do—smaller trees, fewer renders. Stock useState and useContext fight that goal because ownership of a changing value forces the owning component (or every context consumer) to update.

Consider a chat screen that keeps a replyId in local state:

function ChatScreen() {
  const [replyId, setReplyId] = useState('')

  return (
    <View>
      <ChatMessages replyId={replyId} />
      <ChatComposer replyId={replyId} />
    </View>
  )
}

function ChatMessage({ id, replyId }) {
  const isReply = id === replyId

  // ...
}

ChatScreen itself may not need to paint when replyId changes—only the children that read that id do. With useState on the parent, React still re-renders ChatScreen because the hook value changed. Memoization and the React compiler cannot skip that work: the state update is real, so the owner re-renders and then reconciliation walks the children even when most props are unchanged.

useContext sometimes looks like an escape hatch, but it tends to go wide instead of deep: every consumer of a context re-renders when any slice of that context changes, even if a given consumer never reads the changed field. Splitting into many tiny providers helps until the tree looks like this familiar nesting:

function App() {
  return (
    <SomeProvider>
      <SomeOtherProvider>
        <YetAnotherProvider>
          <ThisWillBeTheLastOneProvider>
            <OkJustOneMoreProvider>
              <FinalFinalProvider>
                <SendHelpProvider>
                  <ActualApp />
                </SendHelpProvider>
              </FinalFinalProvider>
            </OkJustOneMoreProvider>
          </ThisWillBeTheLastOneProvider>
        </YetAnotherProvider>
      </SomeOtherProvider>
    </SomeProvider>
  )
}

How Serious Of a Problem Is This Really?

Serious enough to measure. One benchmark compared three placements of a music-app timer that ticks four times per second: state at the app root, midway down the tree, and only in the leaf text component:

// App root
function App() {
  const [time, setTime] = useState(0)

  return (
    <Window>
      <PlaybackArea time={time} />
      <Playlist />
      <BottomToolbar />
    </Window>
  )
}

// Playback area:
function PlaybackArea() {
  const [time, setTime] = useState(0)

  return (
    <View>
      <PlaybackTime time={time} />
      <TheRestOfThePlaybackArea />
    </View>
  )
}

// Leaf text:
function ElapsedText() {
  const [time, setTime] = useState(0)

  return (
    <Text>{time}</Text>
  )
}

In that run, the root-owned timer was about ten times slower than the leaf-owned version. Placement of frequently updating state is not a micro-optimization footnote; it dominates when updates are hot.

What Makes Legend State Different?

A playlist example makes the contrast concrete: a track row’s background should change when that track becomes the active one. A useState version often looks like this:

function MusicApp() {
  const [playback, setPlayback] = useState(initialPlayback);

  return <TrackList activeTrackId={playback.activeTrackId} />;
}

function TrackList({ activeTrackId }) {
  return tracks.map((track) => (
    <TrackRowWrapper
      key={track.id}
      track={track}
      activeTrackId={activeTrackId}
    />
  ));
}

function TrackRowWrapper({ track, activeTrackId }) {
  const isActive = activeTrackId === track.id;

  return <TrackRowView track={track} isActive={isActive} />;
}

Teams try to blunt the cost by computing an isActive boolean in a wrapper and passing it down so a memoized TrackRowView can skip when the flag is unchanged. That helps, yet every setPlayback still re-renders MusicApp, TrackList, and every wrapper in the list—even though only two row views (old active and new active) truly need to update.

Legend State’s answer is that this kind of state should not live in React. You create an observable (sometimes called a signal). Creating the store and subscribing to it are separate steps:

// creates the state
const playback$ = observable({
  tracks: [],
  activeTrackId: null,
});

function TrackRow({ track }) {
  const isActive = useValue(
    // subscribes *only* to playback$.activeTrackId
    () => playback$.activeTrackId.get() === track.id,
  );

  return (
    <View style={[styles.row, isActive && styles.activeRow]}>
      <Text>{track.title}</Text>
    </View>
  );
}

The trailing $ is only a naming habit meaning “observable”; it carries no runtime magic. The playback$ object stays referentially stable. Nested fields change through set() or assign(), but the observable identity does not churn.

// ...

const isActive = useValue(
    () => playback$.activeTrackId.get() === track.id,
  );

// ...

When activeTrackId changes, the selector runs again. A re-render is scheduled only if the boolean result changes. On a 500-track playlist, all 500 selectors may re-evaluate as cheap string compares outside React; for 498 tracks the result is unchanged, so React is never notified. Only the previous and next active rows re-render.

Going Even More Fine-Grained

Even that can be more work than a hot path wants. Changing a background color still re-renders the whole TrackRow. Legend State ships reactive primitives (names prefixed with $) that take reactive props:

function TrackRow({ track }) {
  return (
    <$View
      $style={() => [
        styles.row,
        playback$.activeTrackId.get() === track.id && styles.activeRow,
      ]}
    />
  );
}

TrackRow mounts once and does not re-execute its body on later updates. When activeTrackId changes, only the $style function runs inside a small wrapper the library inserts. The component stops being “a function that re-runs on data change” and becomes “structure set up once, with tiny reactive islands updating themselves.”

The high-performance syntax can look unfamiliar. Day-to-day usage is the observable-plus-useValue pattern from the previous section, which is already fast; the $ components are an escape hatch for measured hot spots.

Going Further

The same architecture enables more than render trimming:

  • useObserveEffect reacts to state changes instead of render cycles, which removes many brittle dependency arrays that useEffect encourages.
  • Updates carry the new value, the previous value, and the path that changed—enough detail to support undo/redo on top of the store.
  • Those detailed change records also power an offline-first sync engine with backend plugins, included in the library with relatively little setup code (a topic large enough for its own talk).

For teams already shopping for a store beyond useState, Legend State is worth a serious look when fine-grained subscriptions and optional reactive primitives matter more than matching the most popular API shape. Start with observables and selective hooks; reach for $-prefixed components only after profiling shows a row or cell that still paints too often. That progression keeps the mental model approachable while leaving headroom for the hottest paths in a React Native UI.