TLDR
If you are curious about the full-working example, it is here.
Background
I recently started experimenting with Next.js 13 and thought it would be a good idea to explore some basic web application paradigms specific to the framework. One topic that always comes up is global state and its persistence, especially in combination with xState.
There is a great resource on the topic here. However, I encountered a few issues while following it, so I decided to document and share my findings.
I used the npx create-next-app@latest
util to scaffold the application.
createActorContext
Recently, xState introduced a useful utility for integrating state machines with React context called createActorContext
. Its implementation is straightforward. The createActorContext(machine)
method takes a machine argument and returns an object that contains a Provider.
export const appMachine = createMachine({
id: "app",
schema: {
events: {} as { type: "GO_TO_STATE_1" } | { type: "GO_TO_STATE_2" },
},
on: {
GO_TO_STATE_1: { target: `state1`, internal: false },
GO_TO_STATE_2: { target: `state2`, internal: false },
},
states: {
state1: {},
state2: {},
},
});
export const AppContext = createActorContext(appMachine);
This creates a globally accessible machine that allows us to switch between different states.
As mentioned in the Next.js documentation, we cannot directly use the AppContext.Provider
in the root layout without changing it to a client component with the use client
directive. To address this, we can add the directive to the file containing our AppContext
and prepare a wrapper for the provider that will be used in the RootLayout
.
"use client";
/* ... */
export function AppProvider({ children }: PropsWithChildren<{}>) {
return <AppContext.Provider>{children}</AppContext.Provider>;
}
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}
Now we can consume our context from any page component and take advantage of the useActorRef
and useSelector
hooks provided by the createActorContext
method. We can use the actor reference to send
events to the appMachine
, and with the useSelector
hook, we can interpret the machine and reduce the number of re-renders if needed.
"use client";
import { AppContext } from "@/contexts/app";
import styles from "./page.module.css";
export default function Home() {
const actor = AppContext.useActorRef();
const stateValue = AppContext.useSelector((state) => state.value);
return (
<main className={styles.main}>
<button
className={`${styles.button} ${
stateValue === "state2" ? styles.selected : ""
}`}
onClick={() => {
actor.send({ type: "GO_TO_STATE_2" });
}}
>
STATE 2
</button>
<button
className={`${styles.button} ${
stateValue === "state1" ? styles.selected : ""
}`}
onClick={() => {
actor.send({ type: "GO_TO_STATE_1" });
}}
>
STATE 1
</button>
</main>
);
}
Currently, the button border of the selected state is painted in red, but we lose this styling after the browser is refreshed.
Persist and rehydrate
Before rehydrating our state, we need to store it somewhere. Since we're working on the web, the localStorage
interface is a perfect choice.
To ensure that we always have the latest state in storage, we can use the third argument of the createActorContext
method, which is an observer that returns the newest state of the machine on every change.
export const AppContext = createActorContext(appMachine, {}, (state) => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
});
Now we can pass the rehydrated state to our context. The AppContext.Provider
has an options
prop, and for our convenience, the type of the state
property in the options
object is the same as the one we stored.
You can read more about why the typeof window !== "undefined"
condition is needed here
function getPersistentAppState() {
if (typeof window !== "undefined") {
return (
JSON.parse(localStorage?.getItem(LOCAL_STORAGE_KEY) ?? "false") ||
appMachine.initialState
);
}
}
export function AppProvider({ children }: PropsWithChildren<{}>) {
return (
<AppContext.Provider options={{ state: getPersistentAppState() }}>
{children}
</AppContext.Provider>
);
}
Finally, after selecting your desired state, you can be sure that it won't be lost.
Conclusion
I still can't fully envision xState's place in the SSR-first world, but it has once again proven itself as one of the best framework-agnostic libraries.
Top comments (3)
Great findings! 👏 createActorContext utility is a useful way to integrate state machines with React context in Next.js.
"in the SSR-first world"
Remember back when the NextJS docs recommended SSG over SSR if you could?
Is there at all a conflict of interest with NextJS and Vercel's need to sell compute services?
If you are going to go all into SSR, is there really much need for React at all?
I'm not saying SSR is bad. It has it's place. Not sold at all on the NextJS let's go full SSR. Even more, very wary of the PHP-like pattern of mixing front end and back end code.
It feels like a bunch of front end devs who have never written or maybe don't understand the value of API services other than for their web app creating another monolitic and tightly coupled structure.
Sort of a side-tangent in that your main topic here is great. It would be cool to extend it to other frameworks too. Solid, Astro, Svelte, Qwik, etc.
Thank you for your comment.
These are my first tries with Next.js, and I'm not using it professionally. I've been avoiding it for some of the reasons you've listed, so I'm not really competent in what was before and after version 13. Since it was promoted as
The React Framework for the Web
, I decided it is time to pay it attention.I take your point that "in the SSR-first world" is a bit far-stretched, but it came from the pure frustration that I had to write "use client" in almost every component, something that does not sit well with me. Nevertheless, my main interest is in xState, and the lack of resources on the topic is what drove me to write this post.
On another note, Solid is the next framework that I've decided to give a try in the near future.