Skip to Content
InitializerConcepts

Concepts

The model, in three sentences

Stages run in order. Tasks within a stage run concurrently. A critical task failing stops the whole run; a critical: false task failing is recorded and the run continues.

tasks={[ initializeAuth, // stage 1 parallel([initializeConfig, initializeDictionary, initializeTheme]), // stage 2, concurrent ]}

That’s the whole ordering model — there’s no implicit dependency graph and no dependsOn. If a step genuinely needs another step’s result, read it from state (below) and put the two steps in separate, ordered stages; there’s no other way to express that one depends on the other.

A duplicate task id still throws synchronously when tasks is validated (at <Initializer> mount, or at createInitializer()), not partway through a run.

Critical vs non-critical failures

critical defaults to true. A critical failure aborts the whole run: the shared AbortSignal trips, no further stages start, and <Initializer> shows the error screen.

Set critical: false for work that genuinely must be attempted before render, but whose failure shouldn’t block it — e.g. loading translations, with a hardcoded fallback locale if that fails. Work that doesn’t need to block first paint at all (analytics, prefetching) usually shouldn’t be in tasks at all — see Non-goals.

'skipped' vs 'cancelled'

Only a condition returning false produces 'skipped'. Everything that never got a chance to run because the whole run was aborted — by a critical failure, or by calling abort() manually — ends up 'cancelled' instead, whether or not it was in the same stage as whatever caused the abort.

Retry and timeout

retry: number is the total number of attempts (default 1, i.e. no retry). retryDelay: number | ((attempt) => number) waits between attempts — not before the first, not after the last — and defaults to 0 (back to back). timeout: number (ms) applies per attempt: if a single attempt doesn’t resolve within the window, it’s treated as a failure (an InitializerTimeoutError) and, if attempts remain, retried.

{ id: 'config', retry: 3, retryDelay: (attempt) => 2 ** attempt * 200, // 400ms, then 800ms timeout: 5000, run: () => fetchConfig(), // each of the 3 attempts gets its own 5s window }

A task whose run checks context.signal can tell when it’s been timed out (the signal trips for that specific attempt) and stop instead of continuing to work in the background.

condition

An optional async predicate deciding whether to run a task at all:

{ id: 'admin', condition: ({ state }) => state.get('user')?.role === 'admin', run: () => loadAdminData(), }

Returning false skips the task. A throwing/rejecting condition is treated exactly like a failed run() — it respects critical the same way.

Shared state

Every task’s run/condition receives { signal, state }. state is a key/value bag, one instance per run, for passing data between tasks without reaching for module-level variables — and it stays readable once the run finishes:

const initializeUser = { id: 'user', run: async ({ state }) => { state.set('user', await fetchCurrentUser()); }, };

Parameterize InitializationTask<{ user: User }> (and createInitializer<{ user: User }>() / <Initializer<{ user: User }>>) to get state.get/.set checked and inferred per key instead of unknown-typed.

The rendering contract

<Initializer> withholds children until status === 'completed' — it shows splashScreen while running, errorScreen on a critical failure, or cancelledScreen if abort() was called. useInitializer() is reachable once any of those is showing (the splash/error/cancelled screens also get what they need directly as props), and is mainly used from children to trigger a later retry() or read getState().

Framework-independent core

The scheduler (runStages, wrapped by createInitializer) has no React import at all. <Initializer> is a thin adapter: it creates a createInitializer() handle in an effect, subscribes to it with useSyncExternalStore, and recreates the handle (a fresh run, fresh state, fresh AbortController) each time retry() is called. See API reference for using the core runner outside of React entirely.

Last updated on