React Offramps: Nanostores

Published on

Increasingly the discontent with the dominant web framework of the last 10 years is growing. IMO this is all long overdue. But others have covered this plenty, so that is not my intended focus here. Rather, I want to talk about a couple of approachable and concrete steps that someone saddled with a legacy or not-so-legacy React or Next.js app can take to help build off ramps so that a project is less tied to a framework built entirely around a outdated technology like React. I am aiming to do a small series on this, but In this post I am focusing subbing out React.Context.

Why Nanostore?

There have been many many options available for non component state or global stores in React. With the advent of the hooks API and React.Context they trend seems to be towards React specific solutions. Nanostores is both framework agnostic and elegantly simple. It is as at least as easy to use as ReactContext, adds very little to bundle sizes and avoids many of the performance pitfalls and DX footguns of React.Context components as well. Additionally Nanostores work in a non React framework / component. Whether it means moving from Next.js to Astro, or maybe someday using a Nanostore in a Web Component that is in your React app or porting your whole app to Svelte, it kind of wouldn’t matter - those are all completely valid options, but also all options you wouldn’t have, or are at least not as readily available with a React specific solutions like Recoil or React centric solution like Zustand or Jotai. I see a lot of value in avoiding framework lock-in, but hey, maybe I am just getting old and tired and the next time I re-write an app, I’d like to be able to re-write less of it.

The Diffs

Here is what converting a React component from using ReactContent to use a basic Nanostore might look like. In this example AppContext was holding user info and a snackbar/toast type message

React Context version

import { useAppContext } from "common/AppContext";
...
function MyNeatComponent() {
  const appContext = useAppContext();
  ...
    appContext.setSnackbar({
      message: "Download error",
      autoHide: true,
      severity: "error",
    });
  ...
    <div>
      {appContext?.user?.roles.includes("canHazCheeseburger") && (<>...</>)}
    </div>

NanoStores version

import { msg } from "common/stores/SnackbarStore";
import { useStore } from "@nanostores/react";
import { user } from "common/stores/UserStore";
...
function MyNeatComponent() {
  const $user = useStore(user);
  ...
    msg.set({
      message: "Download error",
      autoHide: true,
      severity: "error",
    });
  ...
    <div>
      {$user.roles.includes("canHazCheeseburger") && (<>...</>)}
    </div>

Mostly a syntax difference. It would be even less noticeable if you didn’t adopt the $storeVar syntax that is becoming a common convention for denoting mutable state that is managed outside of a component.

Here is what the Nanostore file and for comparison the old ReactContent file.

React Context version

export function AppProvider({ children }: { children: React.ReactNode }) {
	const [snackbarState, setSnackbarState] = useState<SnackbarMessage>(null);
	const [user, setUserState] = useState<IUser>(null);
	const setSnackbar = useCallback((message: SnackbarMessage) => {
		setSnackbarState(message);
	}, []);
	const setUser = (u: IUser) => {
		setUserState(u);
	};

	let appState = {
		user,
		snackbarState,
		setSnackbar,
		setUser,
	};

	return <AppContext.Provider value={appState}>{children}</AppContext.Provider>;
}

export default AppContext;

NanoStores version split this into two store files, but that wouldn’t strictly be necessary:

UserStore.ts

import { map } from "nanostores";

export const user = map<IUser>({
	username: "",
	gateway: "",
	organization: "",
	organization_id: "",
	endorsements: [],
	roles: [],
	sub: "",
	exp: "",
	picture: "",
});

SnackbarStore.ts

import { map } from "nanostores";

export const msg = map<SnackbarMessage>(null);