App state with NgRx SignalStore
signal() s, deep in the components that own them. The store exists for the one piece of data that escapes a single component: the active language and the content tree it resolves.
@ngrx/signals ) fills it without classic NgRx's actions or reducers: read-only signals out, methods in.
Composing features
signalStore is assembled from chained features. withState declares the shape and initial state, withComputed the derived values, withMethods the operations. Every state field becomes a signal exposed on the instance: declaring lang produces store.lang , a Signal<Lang> that any component can read.
lang , content , loading .
patchState , which applies an immutable update and only notifies signals whose value actually changed. loading can flip without re-rendering anything that depends on content .
withComputed . Its derived values (a page title, a breadcrumb, a filtered list of articles) are specific to each screen, so they live in the components that display them, not in shared state. The rule that emerges: only centralize what several views derive identically.
Building state in an injection context
withState accepts two forms: a literal object, or a factory that returns one. The factory runs in the store's injection context, which lets it inject() a service and read localStorage at construction time.
readInitialLang reads the persisted preference, validates it as a Lang , and falls back to the default language if localStorage is unavailable or throws. Deliberately, there's no sniffing of navigator.language : native prerendering and tests start on a deterministic language, never on that of the build machine. It's a choice that costs something elsewhere (a first SSG render always in French) but keeps static generation reproducible.
Stale-while-revalidate, concretely
peek(lang) returns the cached value synchronously: it's what seeds the state, so that the first render and static generation already have content. getContent(lang) does the asynchronous fetch, the real network call eventually.
setLang first swaps the content via peek (synchronous, so the next render is already in the right locale), then kicks off revalidation. The loading flag flips to true for the duration of the fetch, which lets a view show a loading state during the switch.
bundled dictionary is typed Record<Lang, Content> . Adding a language to the LANG value set no longer compiles until its bundle is wired in here. The compiler keeps the list up to date.
Cancelling a stale result
reload protects itself with a last-wins check: before applying a result, it verifies that the current language is still the one it requested.
reload('en') while the store stays on fr , advances simulated time by FETCH_DELAY_MS , and checks that the final content is still FR . The English result is indeed dropped.
An effect inside the store
withHooks gives the store a lifecycle. Its onInit runs in the store's injection context, which lets it open an effect .
store.lang() . On every change, it re-persists the preference and updates the lang attribute on <html> , the one read by screen readers and search engines. The localStorage write is wrapped in a try that swallows the error: a full quota shouldn't break rendering. A test verifies this by making setItem throw, making sure the tick doesn't throw, and that <html lang> still gets updated.
Subscription to tear down by hand.
A facade above the store
ContentStore directly. They go through I18nService , a facade that only re-exposes four things: lang , content , loading , setLang .
/fr , /en , …). It's the route resolver that calls setLang from that prefix, before the component renders. The URL remains the sole source of truth for the language: a shared link to /de/articles opens the page in German without any state needing to be synced by hand.
The threshold for a store
signal() in the component. Adding a store there would bring nothing but indirection.
rxMethod included (which this store doesn't use: an async / await was enough to orchestrate a single fetch).
A SignalStore is a facade of signals: read-only out, methods in, zero reducers. You keep the discipline of a store, and its lifecycle, without yesterday's NgRx ceremony of actions.