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.