This article is a part of the series "Using cookies in React applications"
How to create a responsive React hook to listen for changes to cookies
I recently encountered a situation where I have some React code that needs to listen for changes to a cookie, but this cookie logic isn't controlled React.
Some searches reveal tutorial posts like this one that implement a useCookie
hook.
This works kind of nicely, we get a nice useState
style hook, that also stores into cookies as a side effect, it's helpful for retainining state between sessions.
However, this solution is not responsive to cookie changes that may occur outside of the React context. In fact, two components that were examining the same cookie would not have their updates be observed by the other.
The scenarios where cookies may be set outside of a React context are:
- Cookies being set by HTTP responses
- Cookies being set with JavaScript in your state management layer
We can use the CookieChangeEvent
to make a useCookie
hook truely responsive.
The CookieStore
is not currently supported by Firefox or Safari.
I couldn't a CookieStore polyfill. There is the cookie-store
project - but this has not implemented the CookieStore Monitoring Cookies feature. See this issue: markcellus/cookie-store#46 Monitoring Cookies feature
My solution implements the polling solution described in this Stack Overflow answer
Note that on a page refresh the values initially show as 'John Doe' and then cookie value pops in. This is because this blog is has server rendering, and this these solutions access the client cookies. Cookies are available to the server - a solution that avoids this flash is available in this post here.
The a polyfilled implementation of a responsive useCookie looks like this:
1//@ts-nocheck
2"use client";
3import { useState, useCallback, useEffect } from "react";
4import Cookies from "js-cookie"
5// Define the return type of the hook
6type UseCookieReturn<T> = [
7 T | null, // Value of the cookie
8 (newValue: T, options?: unknown) => void, // Function to update the cookie
9 () => void // Function to delete the cookie
10];
11
12
13// Polyfilled cookieStore wrappers.
14async function getCookie(cookieName: string) : Promise<string | null> {
15 if("cookieStore" in window) {
16 return (await cookieStore.get(cookieName))?.value ?? null;
17 }
18
19 return Cookies.get(cookieName) ?? null;
20}
21
22async function setCookie(cookieName: string, value: string, options?: unknown) : Promise<void> {
23 if("cookieStore" in window) {
24 return cookieStore.set(cookieName, value, options);
25 }
26
27 Cookies.set(cookieName, value, options as CookieAttributes);
28}
29
30async function deleteCookie(cookie: string) : Promise<void> {
31 if("cookieStore" in window) {
32 return cookieStore.delete(cookie);
33 }
34
35 Cookies.remove(cookie);
36}
37
38export function listenForCookieChange(cookieName: string, onChange: (newValue: string | null) => void) : (() => void) {
39
40 // If the cookieStore is available, we can can use the change event listener
41 if("cookieStore" in window) {
42 const changeListener = (event) => {
43 const foundCookie = event.changed.find((cookie) => cookie.name === cookieName);
44 if (foundCookie) {
45 onChange(foundCookie.value);
46 return
47 }
48
49 const deletedCookie = event.deleted.find((cookie) => cookie.name === cookieName);
50 if(deletedCookie) {
51 onChange(null);
52 }
53 };
54
55 cookieStore.addEventListener("change", changeListener);
56
57 // We return a clean up function for the effect
58 return () => {
59 cookieStore.removeEventListener("change", changeListener);
60 };
61 }
62
63 // If cookieStore is not available, we poll for changes.
64 const interval = setInterval(() => {
65 const cookie = Cookies.get(cookieName);
66 if(cookie) {
67 onChange(cookie);
68 } else {
69 onChange(null);
70 }
71 }, 1000);
72
73 // We still return a clean up function for the effect
74 return () => {
75 clearInterval(interval);
76 }
77}
78
79export default function useCookieWithListener<T>(
80 name: string,
81 defaultValue: T
82): UseCookieReturn<T> {
83
84 // For first render, we use the default value
85 // This for SSR purposes - SSR will not have cookies and we don't want a hydration error
86 // Of course this gives you a flash of the default value
87 // It will be up to you to handle this - perhaps you want to display nothing until the cookie is loaded
88 const [value, setValue] = useState<T | null>(defaultValue);
89
90 useEffect(() => {
91
92 // The initial value of the cookie on the client, if it exists
93 getCookie(name).then((cookie) => {
94 if (cookie) {
95 console.log(cookie, typeof cookie)
96
97 try {
98 setValue(JSON.parse(cookie));
99 } catch (err) {
100 setValue(cookie as T);
101 }
102 }
103 });
104
105 // Any subsequent changes to the cookie will be listened to
106 // and set into state
107 return listenForCookieChange(name, (newValue) => {
108 try {
109 setValue(newValue? JSON.parse(newValue) : null);
110 } catch (err) {
111 setValue(newValue as T);
112 }
113 });
114 }, [name]);
115
116 // For updates to the cookie we just update the cookie store directly,
117 // And allow the event listener to update the state
118 const updateCookie = useCallback(
119 (newValue: T, options?: unknown) => {
120 setCookie(name, JSON.stringify(newValue), options);
121 },
122 [name]
123 );
124
125 const _deleteCookie = useCallback(() => {
126 deleteCookie(name);
127 }, [name]);
128
129 return [value, updateCookie, _deleteCookie];
130}
131
Questions? Comments? Criticisms? Get in the comments! 👇
Spotted an error? Edit this page with Github