I have a NextJS project that uses App Router. To manage the state, I use redux-toolkit.
My server component wrapper code for each page is:
const getSSRResult = async <TArg,>(
seed: SSRHandlerSeed<TArg>,
): Promise<{ payload: unknown } | undefined> => {
if (SSRUtil.SSR_FETCH_ENABLED && seed) {
const { logged } = await SSRUtil.getAuthInfoFromCookie();
const rsc = await SSRUtil.isRSC();
if (!logged && !rsc) {
const handler = getSSRHandlerHubItem(seed.key).handler(seed.arg);
try {
const payload = await handler.fetch();
return { payload };
} catch (e) {
const errorData = getErrorData(e);
LogManager.error(
new Date().toLocaleTimeString() +
' ' +
'Error fetching SSR data (key=' +
seed.key +
')',
errorData,
e,
);
}
}
}
return undefined;
};
const SSRWrapper = async <TArg,>({
seed,
children,
}: {
seed: SSRHandlerSeed<TArg>;
children: React.ReactNode;
}) => {
const result = await getSSRResult(seed);
return (
<SSRClientWrapper seed={seed} result={result}>
{children}
</SSRClientWrapper>
);
};
export default SSRWrapper;
That calls the internal wrapper client component:
const useDispatch = <TArg, TResult>(params: {
seed: SSRHandlerSeed<TArg>;
result: { payload: TResult } | undefined;
}) => {
const { seed, result } = params;
const definedRef = React.useRef(false);
const layoutContext = useLayoutContext();
const store = useStore();
const { path } = useMainRouter();
const afterWait = React.useRef(false);
let waitDispatch = false;
if (!definedRef.current && path && layoutContext && result) {
definedRef.current = true;
if (path !== layoutContext.pathname) {
layoutContext.pathname = path;
const handler = getSSRHandlerHubItem(seed.key).handler(seed.arg);
if (handler.selectToIgnore) {
const state = handler.selectToIgnore(store.getState());
waitDispatch = state?.complete ?? false;
}
if (!waitDispatch) {
handler.dispatch({ store, data: result.payload });
}
}
}
React.useEffect(() => {
if (waitDispatch && !afterWait.current && result) {
afterWait.current = true;
const handler = getSSRHandlerHubItem(seed.key).handler(seed.arg);
handler.dispatch({ store, data: result.payload });
}
}, [waitDispatch, seed, result, store]);
};
const SSRClientWrapper = <TArg, TResult>({
seed,
result,
children,
}: {
seed: SSRHandlerSeed<TArg>;
result: { payload: TResult } | undefined;
children: React.ReactNode;
}) => {
useDispatch({ seed, result });
return <>{children}</>;
};
export default SSRClientWrapper;
For the pages that need SSR, I have definitions like:
//...
const privacyPolicy: SimpleDynamicSSRHandler<
void,
{ appInfo: AppInfo; result: GeneralInfo },
GeneralInfoOuterState
> = () => ({
fetch: async () => {
const appInfo = await apiInternalGetAppInfo();
const result = await apiInternalGetPrivacyPolicy();
return { appInfo, result };
},
dispatch: ({ store, data: { appInfo, result } }) => {
store.dispatch(retrieveAppInfo.fulfilled(appInfo, nanoid()));
store.dispatch(fetchPrivacyPolicy.fulfilled(result, nanoid()));
},
selectToIgnore: selectPrivacyPolicyInfoState,
});
const termsOfUse: SimpleDynamicSSRHandler<
void,
{ appInfo: AppInfo; result: GeneralInfo },
GeneralInfoOuterState
> = () => ({
fetch: async () => {
const appInfo = await apiInternalGetAppInfo();
const result = await apiInternalGetTermsOfUse();
return { appInfo, result };
},
dispatch: ({ store, data: { appInfo, result } }) => {
store.dispatch(retrieveAppInfo.fulfilled(appInfo, nanoid()));
store.dispatch(fetchTermsOfUse.fulfilled(result, nanoid()));
},
selectToIgnore: selectTermsOfUseInfoState,
});
//...
The server component at the top that represents the page (page.tsx) is like:
const Page: React.FC = async () => (
<SSRWrapper seed={SSRHandlerHub.termsOfUse.seed()}>
<TermsPage />
</SSRWrapper>
);
export default Page;
TermsPage is the client component with the actual content of the page (it uses Redux state and selectors to show the page content).
My store is inside the layout.tsx file, common to all pages, because some common code uses Redux, and defining the store in the page would be “too late”.
In the example above, when I enter the PrivacyPolicy page through the URL, everything works fine. If I navigate to TermsOfUse the first time, it logs the error:
store.ts:140 Cannot update a component (Main) while rendering a
different component (SSRClientWrapper). To locate the bad setState()
call inside SSRClientWrapper, follow the stack trace as described in
https://react.dev/link/setstate-in-render
I know this error happens because the handler.dispatch() inside my useDispatch hook runs synchronously during render.
I used the Redux docs at https://redux.js.org/usage/nextjs#loading-initial-data as a reference for using a ref to avoid multiple dispatches.
If I force wait whenever layoutContext.pathname is defined (every moment after the first load), I have no error, and the first load have SSR (so, for SEO purposes and OG links, it works fine), but when the user navigates, he will see a spinner/skeleton in the next page and it will blink showing the content (while the way it is now, with the error logs, the user would see the content right after navigating). Furthermore it would cause a fetch to the API at the client side unnecessarily (the navigation would cause a double fetch).
I tried with useLayoutEffect instead of useEffect, but there was no difference.
It’s important to note that this error occurs because the PREVIOUS page selects a state that is updated when the new page loads. In the case above, PrivacyPolicy uses a selector for the AppInfo state, and TermsOfUse updates it. If I load a page that does not depend on the AppInfo state, and navigate to TermsOfUse, no error happens. In the case above, the Main component in the client component of the PrivacyPolicy page (not included in the code above) has a selector for the AppInfo state.
Is there a way to solve this issue, keeping the old global state with the new updated state right after navigating, without errors?
For now, I use an RSC header in production to determine if the page is being loaded the first time for the user, or if it’s an internal navigation, in which case nothing should be loaded (using fetch) on the server (which is also the case for non-authenticated users). With this, except for the 1st load, I lose the benefit of server cache and showing the page with the entire content to the user, and instead he sees a spinner and after the fetch finishes he sees the content. At least this way, there’s no double fetch (because there’s no fetch on the server). This code is already present in the code blocks I posted above, but I would prefer to not have to skip the fetch at the server side when navigating and, instead, it should show the loaded page directly to the user after navigating.