angularngrxngrx-storeangular17angular17-ssr

How to hydrate the ngrx store with Angular 17 and SSR


I have an Angular 17 application that uses server-side rendering. The state of the application is managed using ngrx.

When I access the page, I can see that the page comes pre-rendered (by viewing the source of the page, for example), but once the page loads, Angular seems to start from scratch.

For example, I have some labels that are obtained through an HTTP request. Initially I see the labels in the page, but then Angular starts and clears the state, rendering the labels blank. It then performs the HTTP request to fetch those labels and displays them again. The end-result is correct but there is a flicker when Angular takes over. From what I read it should be reusing the state from the server.

On the browser console, I can see the following:

Angular hydrated 12 component(s) and 98 node(s), 0 component(s) were skipped. Learn more at https://angular.io/guide/hydration.

This seems to indicate the Angular was able to hydrate all my components. I have the Redux dev tools, and by checking them it seems that it is the store that is not being hydrated, as the initial state corresponds to the default state of the store while I would expect it to start from the state that came from the server.

What do I need to do to preserve the state of the store?


Solution

  • I found the following solution. I registered a meta reducer in the provideStore call. When called on the server, it captures the state of the store and adds it to the TransferState. On the client, it intercepts the INIT action and restores the state that was stored in the transfer state.

    Explanation

    As its name indicates, TransferState allows to transfer data between the server and the client. For example, it is used by Angular to transfer the HTTP cache to the client.

    A meta reducer is a function that can wrap an existing reducer, giving you a point where you can intercept all the actions that occur in the store.

    By taking advantage of these two mechanisms, we are able to add the parts of the store that we want to have immediately available in the client. Note that you may not want to transfer the whole store, because since Angular already transfers the HTTP cache, any cacheable data that was requested on the server will be immediately available to the client as well.

    Example

    provideStore({
        router: routerReducer,
    }, {
        metaReducers: [
            reducer => {
                const storeStateKey = makeStateKey<string>("storeState");
                const platformId = inject(PLATFORM_ID);
                const transferState = inject(TransferState);
    
                if (isPlatformServer(platformId)) {
                    let lastState: any = {};
                    transferState.set(storeStateKey, lastState);
    
                    // Only include the keys that are useful to access immediately
                    transferState.onSerialize<any>(storeStateKey, () => ({
                        app: lastState["app"],
                        topMenu: lastState["topMenu"],
                        localization: lastState["localization"]
                    }));
    
                    return (state, action) => {
                        lastState = reducer(state, action);
                        return lastState;
                    };
                } else {
                    return (state, action) => {
                        const next = reducer(state, action);
                        if (action.type === INIT) {
                            const initialState = transferState.get<any>(storeStateKey, {});
                            return { ...next, ...initialState };
                        }
                        return next;
                    };
                }
            }
        ]
    }),
    

    Edit: The above method is called from:

    export const appConfig: ApplicationConfig = {
      providers: [
        // ...
        makeEnvironmentProviders([
            provideStore(...)
        ]),
        // ...
    }
    
    bootstrapApplication(AppComponent, appConfig)
      .catch((err) => console.error(err));