Cookies and SSR with React and Next.js

Today I was working on something for Islebuilds which is supposed to be server-side rendered on first load and then client-side rendered afterwards. Because of this, we need to do some extra stuff if we want to use cookies without having hydration errors. I was having some issues with react-cookie so I decided to write my own useCookie hook which is documented here.

I started by replacing react-cookie with cookies-next instead. But cookies-next doesn't have React hooks in it, just methods to read and set cookies on the client, so we'll write out own.

1
const useCookie = (key: string, defaultValue: string) => {
2
    const [state, setState] = useState<string>(() => {
3
        // Initialize `state` to the cookie and default to the defaultValue
4
        return getCookie(key)?.toString() ?? defaultValue;
5
    });
6

7
    const setStateWrapper = (action: string | ((prev: string) => string)) => {
8
        // Set `state` to the passed value or the result of the updater function
9
        const newValue = typeof action === 'function' ? action(state) : action;
10
        setState(newValue);
11

12
        // Update the cookie as well
13
        setCookie(key, newValue);
14
    };
15

16
    return [state, setStateWrapper] as const;
17
};

Pretty straightforward. However, we start having issues when there are multiple uses of useCookie acting on the same key. If the first instance's setStateWrapper is called, the cookie will be updated and its internal state will change but other instance's of useCookie will not be notified.

To solve this, we can store a map of keys and listeners:

1
const cookieListeners: Record<string, ((newValue: string) => void)[]> = {};

And then register a listener inside of useCookie.

1
const useCookie = (key: string, defaultValue: string) => {
2
    /* ... useState, setStateWrapper ... */
3

4
    useEffect(() => {
5
        // Set our internal state when the listener is called
6
        const listener = (value: string) => {
7
            setState(value);
8
        };
9

10
        // Add ourselves to the listener map
11
        if (!cookieListeners[key]) {
12
            cookieListeners[key] = [];
13
        }
14
        cookieListeners[key].push(listener);
15

16
        // Return a cleanup function that removes our listener
17
        return () => {
18
            cookieListeners[key] = cookieListeners[key].filter(
19
                (l) => l !== listener
20
            );
21
        };
22
    });
23

24
    /* ... */
25
};

We also need to change setStateWrapper to notify other listeners of the new value.

1
const setStateWrapper = (action: string | ((prev: string) => string)) => {
2
    const newValue = typeof action === 'function' ? action(state) : action;
3

4
    setCookie(key, newValue);
5

6
    // Call each listener (including our own) with newValue to update each hook's internal state
7
    cookieListeners[key].forEach((listener) => listener(newValue));
8
};

Everything works with client-side rendering now!


Adding support for SSR

Next, we need to provide the correct cookies on the server to make sure SSR and CSR produce the same page. We'll do this using React context.

1
const CookiesContext = createContext<Record<string, string>>({});
2
const CookiesProvider = CookiesContext.Provider;

Then, inside useCookies, we'll initialize our internal state differently during SSR. cookies-next supports SSR by passing req and res into getCookie, but we'll do it a little differently.

1
const useCookie = (key: string, defaultValue: string) => {
2
    // Get the cookies provided by the context
3
    const ssrCookies = useContext(CookiesContext);
4

5
    const [state, setState] = useState<string>(() => {
6
        // Check if we are being SSRed
7
        if (typeof window === 'undefined') {
8
            // Use the value from the context or the default value
9
            return ssrCookies[key] ?? defaultValue;
10
        }
11

12
        return getCookie(key)?.toString() ?? defaultValue;
13
    });
14

15
    /* ... */
16
}

We also need to wrap our app with a CookiesProvider. This project is still using Next.js 12 at the time of writing, so the code for Next.js 13 or other frameworks might be slightly different.

Basically, the whole app has a getInitialProps in _app.tsx which parses the cookies and passes them to the provider.

1
import { parse as parseCookies } from 'cookie';
2

3
const MyApp = ({ Component, pageProps }) => {
4
    /* ... Lots of other stuff removed ... */
5

6
    return (
7
        <CookiesProvider value={pageProps.cookies ?? {}}>
8
            <Component {...pageProps} />
9
        </CookiesProvider>
10
    );
11
};
12

13
MyApp.getInitialProps = async (appContext) => {
14
    const appProps = await App.getInitialProps(appContext);
15

16
    const ctx = appContext.ctx;
17

18
    const cookies: string = ctx?.req?.headers?.cookie ?? '';
19
    const parsedCookies = parseCookies(cookies);
20

21
    return {
22
        ...appProps,
23

24
        pageProps: {
25
            ...appProps.pageProps,
26
            cookiesString: cookies,
27
            cookies: parsedCookies,
28
        },
29
    };
30
};

I didn't have a need to support setting cookies during SSR so that probably doesn't work with this code.


useBooleanCookie helper

Here's another short hook that wraps useCookie to handle boolean values.

1
const useBooleanCookie = (key: string, defaultValue: boolean) => {
2
    const [cookie, updateCookie] = useCookie(key, defaultValue.toString());
3

4
    const value =
5
        cookie === 'true' ? true : cookie === 'false' ? false : defaultValue;
6

7
    const setValue = (action: React.SetStateAction<boolean>) => {
8
        const newValue = typeof action === 'function' ? action(value) : action;
9

10
        updateCookie(newValue.toString());
11
    };
12

13
    return [value, setValue] as const;
14
};

You could easily write another hook like useNumberCookie or some other type to provide validation and types as needed.


Complete code for useCookie

The final code for useCookie looks something like this:

1
export const CookiesContext = createContext<Record<string, string>>({});
2
export const CookiesProvider = CookiesContext.Provider;
3

4
const cookieListeners: Record<string, ((value: string) => void)[]> = {};
5

6
export const useCookie = (key: string, defaultValue: string) => {
7
    const ssrCookies = useContext(CookiesContext);
8

9
    const [state, setState] = useState<string>(() => {
10
        if (typeof window === 'undefined') {
11
            return ssrCookies[key] ?? defaultValue;
12
        }
13

14
        return getCookie(key)?.toString() ?? defaultValue;
15
    });
16

17
    useEffect(() => {
18
        const listener = (value: string) => {
19
            setState(value);
20
        };
21

22
        if (!cookieListeners[key]) {
23
            cookieListeners[key] = [];
24
        }
25

26
        cookieListeners[key].push(listener);
27

28
        return () => {
29
            cookieListeners[key] = cookieListeners[key].filter(
30
                (l) => l !== listener
31
            );
32
        };
33
    });
34

35
    const setStateAndPublish = (action: React.SetStateAction<string>) => {
36
        const newValue = typeof action === 'function' ? action(state) : action;
37

38
        setCookie(key, newValue);
39

40
        cookieListeners[key].forEach((listener) => listener(newValue));
41
    };
42

43
    return [state, setStateAndPublish] as const;
44
};

Thanks for reading!

kckckc © 2023