Skip to content

PATTERN Cited by 1 source

Application-State layer outside React

Pattern. Put the state that drives a React application in a central store that sits outside the component tree (shape: Redux-family), let your framework resolve and write data into it, and read from it in components via a Connector hook that either returns the value or throws a Promise — suspending React on that Suspense boundary until the store makes the value available.

This is an alternative to the canonical Render-As-You-Fetch pattern in which the render tree initiates fetching via hooks. In render-as-you-fetch the render walk drives data resolution; in this pattern data resolution is a separate pipeline and the render tree is a passive consumer that knows how to suspend.

Shape

┌────────────────────────────┐       ┌──────────────────────────┐
│ Data-resolution pipeline    │ writes │ Application State (store)│
│ (e.g. RE's resolveEntity;   │ ─────► │ - Renderer data by key   │
│  any Redux-shaped middleware│       │ - Promise for pending key│
│  that fetches, transforms)  │       └──────────────────────────┘
└────────────────────────────┘                 ▲ reads
                                ┌──────────────┴──────────────┐
                                │ Connector hook (useSelector-│
                                │ like): returns value OR     │
                                │ throws Promise              │
                                └──────────────┬──────────────┘
                                               │ suspend/resume
                                ┌──────────────▼──────────────┐
                                │ React component (wrapped in │
                                │ <Suspense fallback=...>)    │
                                └─────────────────────────────┘

Connector-hook behaviour

The Connector hook's contract:

function useConnector(selector) {
  const state = store.getState();
  const value = selector(state);
  if (value !== undefined) return value;

  // Not yet resolved — create or retrieve a pending Promise that
  // resolves when the store next produces this value.
  const promise = store.awaitSelector(selector);
  throw promise; // React catches this and suspends.
}

Zalando's post frames it exactly this way: "Imagine Redux's useSelector hook, but instead of immediately returning selected data you get a Promise that only resolves once a reducer has made the data available." (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

Why not pure Render-As-You-Fetch?

The Zalando Rendering Engine post names four reasons (see concepts/render-as-you-fetch for the full analysis):

  1. SuspenseList is experimental and limited — ordered streaming/hydration (required for a visually-ordered page) can't be guaranteed.
  2. useTransition doesn't consider nested Suspense boundaries — for deeply-nested trees (page → collection → card), UX under transitions is bad.
  3. Hook-driven fetch timing couples to render order — which is a performance anti-pattern when siblings could fetch in parallel.
  4. React's streaming/caching layer for data supply wasn't final at PoC time — progressive hydration needs the data that drove SSR to be in the client before the client re-fetches.

The Application-State pattern resolves all four:

  1. Data resolution runs in a known order defined by the pipeline (not by the render walk).
  2. Transitions are state-store concerns, not component-tree concerns.
  3. Fetches are batched/parallelised at the pipeline layer, independent of rendering.
  4. The state store is the client-side cache; its hydration from server-rendered state is under your control, not dependent on any React-internal cache protocol.

Rendering Engine specifics

Zalando's Rendering Engine instantiates this pattern as:

  • The pipeline stage resolveEntity — walks the Entity tree, matches each Entity to its Renderer per the rendering rules, resolves the Renderer's data (GraphQL query, tracking config, A/B-test slot), and writes the output into Application State keyed by the Entity's place in the tree.
  • <Suspense>-wrapped Renderer components — each Renderer is wrapped by RE, not by the Renderer author. Feature teams write straight-line render logic.
  • A Connector hook — Renderers read their data via this hook, suspending if resolveEntity hasn't written to the state yet.

"Everytime RE finds the matching Renderer and resolves all its corresponding data for an Entity definition (through 'resolveEntity' step), the output will be written to the Application State layer. In the meantime React is rendering the Renderer components which are wrapped with Suspense." (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

What this pattern enables

  • Custom ordering of suspension. The pipeline controls what resolves when; render order follows.
  • Nested Suspense handled explicitly. The state store knows about tree hierarchy; when a parent's data is ready it can decide whether to unsuspend the parent alone or also unsuspend children.
  • Transitions manageable at state layer. Rather than relying on useTransition's nested-boundary behaviour.
  • Integration with non-React cross-cutting concerns — tracking, monitoring, A/B testing all fit cleanly at the pipeline layer; Renderers stay pure rendering functions.
  • Predictable SSR/CSR data cache. The state store is the cache; rehydration is a state-deserialisation step, not a React-cache-protocol step.

What it costs

  • More code to write and maintain. You own the pipeline, the state store, the Connector hook, the SSR/CSR serialisation contract. If React's built-in pattern works for your app, you're rewriting what the framework gives you.
  • Framework-level investment. This pattern needs a framework (like RE) to be worth doing — it's not an application-level choice.
  • Team cost. Renderer authors must understand Suspense semantics well enough not to accidentally bypass the Connector or introduce side-effects that break the pipeline.
  • Divergence from community tooling. React DevTools, Suspense-aware profilers, and general React patterns assume render-as-you-fetch. Custom state-store approaches work but tools are less turnkey.

When to reach for it

  • You have a framework team owning page rendering for many feature teams, and centralising cross-cutting concerns is the load-bearing reason the framework exists.
  • Render ordering is business-logic-driven — as in RE, where personalisation picks the tree and teams don't get to pick suspension ordering ad-hoc.
  • You already have a Redux-family state store and a pipeline that populates it for reasons unrelated to React (logging, A/B test evaluation, tracking).
  • You need deterministic nested-Suspense behaviour across diverse component trees.

When not to

  • Small or medium apps. The framework-level overhead isn't earned back.
  • Framework already provides render-as-you-fetch (Next.js App Router + React Server Components, Remix with loaders).
  • No central state store — introducing one as a way to avoid hook-based fetching is backwards.

Seen in

  • sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-reactcanonical wiki instance. Rendering Engine's 2023 React-18 migration chose this pattern over pure render-as-you-fetch. The Connector-hook-as-Redux-useSelector- but-throws-Promise framing is explicit in the post. The ordered-streaming implementation (which this pattern enables but does not itself specify) is deferred to a follow-up post.
Last updated · 501 distilled / 1,218 read