DEV Community

Cover image for Persistent global state with xState and Next.js
Georgi Todorov
Georgi Todorov Subscriber

Posted on • Edited on

Persistent global state with xState and Next.js

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);
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppProvider>{children}</AppProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
bkpecho profile image
Bryan King Pecho

Great findings! 👏 createActorContext utility is a useful way to integrate state machines with React context in Next.js.

Collapse
 
brianmcbride profile image
Brian McBride

"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.

Collapse
 
gtodorov profile image
Georgi Todorov

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.