This lib explores an approach to shared state management and routing in React apps that seeks to be an extension to common React patterns in order to be expressed with concise and familiar code.
Core parts:
- Shared state management: useExternalState
- Routing: useRoute · useNavigationStart / useNavigationComplete · useRouteState · Type-safe routes
- Async state management: useTransientState
Under the hood, routing is a special case of shared state management, too, with the shared state being the URL. So all these core parts fall under the broad category of state management, with useExternalState() being the general-purpose React hook for arbitrary state management (which is also the basis of the lib's other hooks).
+ Annotated live demos · Internals · Motivation and comparison
This hook is focused on simplicity of both setting up shared state from scratch and migrating from local state. The equally common latter scenario is often missed out with commonly used approaches resulting in sizable code rewrites.
Move local state to the full-fledged shared state with minimal paradigm shift and minimal code changes:
+ import { State, useExternalState } from "react-statepack";
+
+ const counterState = new State(0);
function Counter() {
- const [count, setCount] = useState(0);
+ const [count, setCount] = useExternalState(counterState);
const handleClick = () => setCount((c) => c + 1);
return <button onClick={handleClick}>+ {count}</button>;
}
function ResetButton() {
- const [, setCount] = useState(0);
+ const [, setCount] = useExternalState(counterState);
const handleClick = () => setCount(0);
return <button onClick={handleClick}>×</button>;
}
export function App() {
return (
<>
<Counter/>{" "}<ResetButton/>
</>
);
}⯈ Live demo: Shared state without Context
The shared state instance new State(initialValue) can contain data of any type, primitive or nonprimitive.
With TypeScript, the value type of a State instance is inferred from the initial value, or it can be explicitly specified with a generic type as new State<T>(value).
With SSR, it's common practice to put shared values into React Context rather than module-level variables to avoid cross-request data sharing. The same applies to external state. Provide external state to multiple components via React Context like any data in a React app:
+ import { createContext, useContext } from "react";
- import { State, useExternalState } from "react-statepack";
+ import { State, useExternalState, useInitialValue } from "react-statepack";
- const counterState = new State(0);
+ const AppContext = createContext(null);
function Counter() {
- const [count, setCount] = useExternalState(counterState);
+ const [count, setCount] = useExternalState(useContext(AppContext));
const handleClick = () => setCount((c) => c + 1);
return <button onClick={handleClick}>+ {count}</button>;
}
function ResetButton() {
- const [, setCount] = useExternalState(counterState);
+ const [, setCount] = useExternalState(useContext(AppContext));
const handleClick = () => setCount(0);
return <button onClick={handleClick}>×</button>;
}
export function App() {
+ const appState = useInitialValue(() => new State(0));
return (
- <>
+ <AppContext.Provider value={appState}>
<Counter/>{" "}<ResetButton/>
- </>
+ </AppContext.Provider>
);
}⯈ Live demo: Shared state with Context
⬥ The useInitialValue(initializer) hook returns a value that remains unchanged across potential re-renders by calling the initializer function only once. Unlike useState() with an initializer function, useInitialValue() seeks to bypass extra initializer calls in development Strict Mode, too. Under the hood, this hook wraps up the common pattern of stabilizing values with React's useRef().
⬥ The application state can also be split across multiple instances of State and multiple Contexts to maintain clearer semantic boundaries and more targeted data update subscriptions.
import { createContext, useContext } from "react";
import { State, useExternalState, useInitialValue } from "react-statepack";
const AppContext = createContext(null);
function ItemList() {
const [items] = useExternalState(useContext(AppContext).items);
return (
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
export function App() {
// Multi-instance state
const appState = useInitialValue(() => ({
users: new State(/* User data, e.g. a map of <id>: <user info> */),
items: new State(/* Items, e.g. a map of <id>: <item info> */),
}));
return (
<AppContext.Provider value={appState}>
<ItemList/>
{/* ... */}
</AppContext.Provide>
);
}⬥ Note that updating the State value doesn't change the instance's reference sitting in the React Context and therefore doesn't cause updates of the entire Context. Only the components subscribed to updates of the particular State instance by means of useExternalState(state) will be notified to re-render.
⬥ useExternalState(state) accepts null and undefined values as state, but throws an error if such a state value is encountered. This helps handle empty (typically null) default Context values without repetitive checks and follows a common pattern.
Use the select(state, selector) utility with useExternalState() to pick a state value derived from state as defined by the selector() function:
import { State, useExternalState, select } from "react-statepack";
const appState = {
items: new State({/* ... id: { name: string } */}),
// ... Rest of the shared application data
};
function ItemCard({ id }: { id: string }) {
const [name] = useExternalState(...select(
appState.items, // State instance
(items) => items[id]?.name, // Selector
));
return <div className="item">Name: {name}</div>;
}
export function App() {
return (
<>
<ItemCard/>
{/* ... */}
</>
);
}Or, alternatively, with a React Context:
import { createContext, useContext } from "react";
import { State, useExternalState, useInitialValue, select } from "react-statepack";
const AppContext = createContext(null);
function ItemCard({ id }: { id: string }) {
const [name] = useExternalState(...select(
useContext(AppContext)?.items, // State instance
(items) => items[id]?.name, // Selector
));
return <div className="item">Name: {name}</div>;
}
export function App() {
const appState = useInitialValue(() => ({
items: new State({/* ... id: { name: string } */}),
// ... Rest of the shared application data
}));
return (
<AppContext.Provider value={appState}>
<ItemCard/>
{/* ... */}
</AppContext.Provider>
);
}⬥ With a selector subscription to a State instance, the component will only be notified to re-render when the state snapshot returned from the selector changes. By default, whether there's no value change is determined as shallow equality of the previous and current snapshots. Optionally, another equality function can be set as the third parameter of select().
⬥ The state setter returned from useExternalState(...select(state, selector)) alongside the selector snapshot updates the entire state regardless of the selector.
Use the optional second parameter of useExternalState(state, subscribe?) for more fine-grained control over component's subscription to the State instance and re-renders:
const itemState = new State({/* A map of `<id>: <item>` */});
// Renders a specific item from `itemState`
function ItemCard({ id }) {
const [items, setItems] = useExternalState(itemState, (render) => {
// The subscription to the state events returns a cleanup function.
return itemState.on("update", ({ current, previous }) => {
// Assuming that the items have a `timestamp` property, re-render
// `ItemCard` only if the relevant item's `timestamp` has increased
if (current[id].timestamp > previous[id].timestamp) render();
});
});
// ...
}⬥ Set the subscribe parameter to false, as in useExternalState(state, false), to tell the hook not to subscribe the component to tracking the external state updates altogether. A use case for it is when a component makes use of the external state value setter without using the state value itself. The false parameter could have been used in the ResetButton in the first example above, but in many cases of infrequent updates with lightweight component renders it might be unnecessary, since React automatically skips updating the DOM when there are no changes.
⬥ Under the hood, the select(state, selector) utility, discussed in the previous section, creates a custom subscription based on the selector function. This utility function also makes use of the optional third parameter of useExternalState(), getShapshot(), a function that returns a value derived from state.
The same way as with useState(), Immer can be optionally used with state setters returned from useExternalState() to facilitate deeply nested data changes.
⯈ Live demo: Shared state with Immer
Replace State with PersistentState as shown below to get the state data synced to the specified key in localStorage and restored on page reload. After a persistent state is created, use it with useExternalState(state) the same way as State instances.
import { PersistentState } from "react-statepack";
const counterState = new PersistentState(0, { key: "counter" });⬥ Set options.session to true in new PersistentState(value, options) to use sessionStorage.
⬥ Set options.serialize() and options.deserialize() to override the default data transform behavior, including filtering and rearranging the data (it's JSON.stringify() and JSON.parse() by default).
⬥ Set up interaction with a custom storage by setting { read(), write(value)? } as options in new PersistentState(value, options).
⬥ PersistentState skips interaction with the browser storage in non-browser environments, which makes it usable with SSR. Since persistent state depends on a browser API (localStorage or sessionStorage) by default, mismatch errors while hydrating SSR content based on a persisent state should be specifically handled with React's use(browser()) (v19.3+) or other client-side rendering detection techniques such as react-clientside.
Use this hook for URL-based rendering and SPA navigation. Under the hood, this boils down to accessing and changing the shared URL state.
URL-based rendering with at(url, x, y?) shown below works similarly to conditional rendering with the ternary operator atURL ? x : y. It's equally applicable to props and components:
import { useRoute } from "react-statepack";
function App() {
const { at } = useRoute();
return (
<header className={at("/", "full", "compact")}>
<h1>App</h1>
</header>
{at("/", <Intro/>)}
{at(/^\/sections\/(?<id>\d+)\/?$/, ({ params }) => <Section id={params.id}/>)}
);
}⯈ Live demo: URL-based rendering
⬥ params in dynamic values (as in ({ params }) => <Section id={params.id}/> above) contains the URL pattern's capturing groups.
⬥ Use at(url) as a shorthand for at(url, true, false). Example: <Component isOpen={at("/")}/>.
⬥ By default, useRoute makes use of the browser's URL, if it's available. Otherwise, use <RouteProvider href={url}> to set a specific URL value. Common use cases: SSR and tests. A less common use case: custom routing behavior, including custom non-URL-based routing. ⯈ Live demo: Custom routing based on text input
⬥ See also the Type-safe routes section. ⯈ Live demo: Type-safe URL-based rendering
⬥ Routing with react-statepack is based on the core idea behind all approaches to route-based rendering: conditional rendering based on the URL. Unlike component-, config-, or file-based approaches, the react-statepack's imperative approach sticks to this core idea without additional abstraction layers and specific relations between routes (like layout nesting or parameter inheritance) offering full explicit control over route-based rendering.
The shape of the SPA navigation API is largely aligned with the similar built-in browser APIs (while still being compatible with SSR):
+ import { A, useRoute } from "react-statepack";
function UserNav({ signedIn }) {
+ const { route } = useRoute();
const handleClick = () => {
- window.location.href = signedIn ? "/profile" : "/login";
+ route.href = signedIn ? "/profile" : "/login";
};
return (
<nav>
- <a href="/">Home</a>
+ <A href="/">Home</A>
<button onClick={handleClick}>Profile</button>
</nav>
);
}⬥ <A> and <Area> are the two kinds of SPA route link components available out of the box. They have the same props and semantics as the corresponding HTML link elements <a> and <area>.
⬥ The route object returned from useRoute() exposes an API resembling the built-in APIs of window.location and history carried over to SPA navigation: .assign(url), .replace(url), .reload(), .href, .pathname, .search, .hash, .back(), .forward(), .go(delta).
⬥ While route.assign(url) and route.replace(url) serve as a handy drop-in replacement for the similar window.location methods, route.navigate(options) combines and extends these methods:
route.navigate({ href: "/intro", history: "replace", scroll: "off" });
The navigation options, used with route.navigate(options), include:
href, a target URL;referrer, an original URL prior to the navigation;history: "push" | "replace", controlling whether to save the navigation to the browser history session or not (default:"push", save);target, accepting the HTML linktargetattribute values;scroll: "auto" | "off", controlling whether the content should be automatically scrolled to the top after the navigation, similarly to the ordinary link navigation ("auto", default), or not ("off");spa: "auto" | "off", controlling whether the navigation is in the SPA mode ("auto", default) or requires a full-page reload ("off");id, an optional ID that can be used in navigation handlers to identify a specific navigation.
⬥ Tweak link components by adding a relevant combination of the optional data- props corresponding to the navigation options outlined above:
<A href="/intro">Intro</A>
<A href="/intro" data-history="replace">Intro</A>
<A href="/intro" data-scroll="off">Intro</A>
<A href="/intro" data-spa="off">Intro</A>
<A href="/intro" data-id="header-intro-link">Intro</A>Using props compatible with HTML link attributes makes link components easily interchangeable with HTML links. For this reason, the link components' navigation is shaped by the standard and familiar href and target props, while the rest of the navigation options can be specified as data- attibutes.
⬥ Link components also automatically receive the data-active="true" prop whenever their href prop matches the current URL, which can be used for additional styling.
⬥ Use the optional callback parameter of useRoute(callback?) for more fine-grained control over the component rendering in response to URL changes. This callback receives the render function as a parameter that should be called at some point. Use cases for this render callback include, for example, activating animated view transitions (⯈ Live demo: View transitions) or (less likely in regular circumstances) skipping re-renders for certain URL changes.
These hooks set up optional actions to be done before and after a SPA navigation occurs respectively. Such intermediate actions are also known as routing middleware.
Some common examples of what can be handled with the routing middleware include redirecting to another URL, preventing navigation with unsaved user input, setting the page title based on the current URL:
import { useNavigationComplete, useNavigationStart } from "react-statepack";
function setTitle({ href }) {
document.title = href === "/intro" ? "Intro" : "App";
}
function App() {
const { route } = useRoute();
const [hasUnsavedChanges, setUnsavedChanges] = useState(false);
const handleNavigationStart = useCallback(({ href }) => {
if (hasUnsavedChanges)
return false; // Preventing navigation
if (href === "/") {
route.href = "/intro"; // SPA redirection
return false;
}
}, [hasUnsavedChanges, route]);
useNavigationStart(handleNavigationStart);
useNavigationComplete(setTitle);
// ...
}⯈ Live demo: Routing middleware
⬥ The object parameter of the hooks' callbacks has the shape of the navigation options, including href and referrer, the navigation destination and initial URLs.
⬥ The callback of both hooks is first called when the component gets mounted if the route is already in the navigation-complete state.
When it's necessary to put a portion of the app's state to the URL, use this hook to manage URL parameters as state in a useState-like manner. Use the React's state mental model and migrate from local state without major code rewrites:
+ import { useRouteState } from "react-statepack";
function App() {
- const [{ coords }, setState] = useState({ coords: { x: 0, y: 0 } });
+ const [{ query }, setState] = useRouteState("/");
const setPosition = () => {
setState(state => ({
...state,
- coords: {
+ query: {
x: Math.random(),
y: Math.random(),
},
});
};
return (
<>
<h1>Shape</h1>
- <Shape x={coords.x} y={coords.y}/>
+ <Shape x={query.x} y={query.y}/>
<p><button onClick={setPosition}>Move</button></p>
</>
);
}⯈ Live demo: URL parameters as state
⬥ useRouteState(url, options?) has an optional second parameter in the shape of the navigation options. Pass { scroll: "off" } as options to opt out from the default scroll-to-the-top behavior when the URL changes.
⬥ See also the Type-safe routes section. ⯈ Live demo: Type-safe URL parameters as state
When it comes to accessing parameters extracted from a URL pattern, by default the parameters are typed as Record<string, string | undefined>, which quite literally represents a map containing portions of a string URL.
const { at } = useRoute();
at(/^\/sections\/(?<id>\d+)\/?$/, ({ params }) => <Section id={params.id}/>)
// ^ Record<string, string | undefined>
const [state, setState] = useRouteState("/");
// ^ { query: Record<string, string | undefined> }⯈ Loosely typed routing demos: URL-based rendering · URL parameters as state
While the loosely typed routing as shown above works just fine, optionally, more specific type-aware parsing of URL parameters can be achieved by replacing string and RegExp URL patterns with URL patterns produced by a schema-based URL builder, like with url-shape and zod or a similar tool:
import { createURLBuilder } from "url-shape";
import { z } from "zod"; // Or another Standard Schema-compliant lib
// Get a type-aware URL builder `url()` based on a URL schema
export const url = createURLBuilder({
"/sections/:id": z.object({
// URL path placeholder parameters
params: z.object({ id: z.coerce.number() }),
}),
"/": z.object({
// URL query (or search) parameters
query: z.optional(z.object({ x: z.coerce.number(), y: z.coerce.number() })),
}),
});The type-aware URL builder url(pattern, options?) provides hints about the types of the parsed URL parameters and helps avoid typos and type mismatches:
const { at } = useRoute();
at(url("/sections/:id"), ({ params }) => <Section id={params.id}/>)
// ^ { id: number }
const [state, setState] = useRouteState(url("/"));
// ^ { query?: { x: number, y: number } }
<A href={url("/sections/:id", { id: 1 })}>Section 1</A>
// ^ { id: number }⯈ Live demos: Type-safe URL-based rendering · Type-safe URL parameters as state
The URL schema as shown above doesn't have to cover the entire app. This approach allows for incremental or partial adoption of type-safe routing, where needed.
On the other hand, once the entire app is covered with type-safe routes, it might be reasonable to prevent future use of relaxed typing with string and RegExp URL patterns. This can be achieved by adding the following type declaration that effectively disallows string and RegExp URL patterns:
declare module "react-statepack" {
interface URLConfig {
strict: true;
}
}All routes are handled independently, so nested routes don't require special handling and don't maintain implicit relations with other routes. This also means that nested routes don't inherit their parent route parameters by default.
With type-safe routes, any practical relations between them can be transparently defined at the URL schema level, without default implicit constraints, too. Here's an example:
import { createURLBuilder } from "url-shape";
import { z } from "zod";
const sectionParams = z.object({
sectionId: z.coerce.number(),
});
export const url = createURLBuilder({
"/sections/:sectionId": z.object({
params: sectionParams,
}),
"/sections/:sectionId/stories/:storyId": z.object({
params: z.object({
...sectionParams.shape, // Shared URL parameters
storyId: z.coerce.number(),
}),
}),
});⯈ Live demo: Type-safe nested routes
Use this hook to track an async action's state, whether it's pending, successfully completed, or failed, without affecting the application's data management.
In the example below, storing and rendering the essential app data (items) and the happy path scenario remain unaffected. The loading and error state handling works like a decoupled scaffolding to the main scenario. (items are stored in local state here, but any other state used by the app can be there instead.)
+ import { useTransientState } from "react-statepack";
- import { fetchItems } from "./fetchItems.js";
+ import { fetchItems as fetchItemsOriginal } from "./fetchItems.js";
export function ItemList() {
const [items, setItems] = useState([]);
+ const [state, fetchItems] = useTransientState("items", fetchItemsOriginal);
useEffect(() => {
// The fetched items can be stored with any approach to app state
fetchItems().then(setItems);
}, [fetchItems]);
+ if (state.initial || state.pending) return <p>Loading...</p>;
+ if (state.error) return <p>An error occurred</p>;
return <ul>{items.map(/* ... */)}</ul>;
}+ import { useTransientState } from "react-statepack";
- export function Status({ state }) {
+ export function Status() {
+ const [state] = useTransientState("items");
if (state.initial) return null;
if (state.pending) return <>Busy</>;
if (state.error) return <>Error</>;
return <>OK</>;
}⯈ Live demo: Shared async action state
Use a string key with useTransientState(key, action?) to access the same action state from multiple components (as in ItemList and Status above). Pass null as the key to have the action state scoped locally to the component where the hook is used.
Use case: background actions or optimistic updates.
Set { silent: true } as the last parameter of the trackable action returned from the useTransientState hook to prevent the pending property from switching to true in the pending state.
const [state, fetchItems] = useTransientState(fetchItemsOriginal);
// ^ `state.pending` remains `false` in the silent mode
fetchItems({ silent: true })Use case: Avoid flashing a process indicator when the action is likely to complete in a short while by delaying the pending state.
const [state, fetchItems] = useTransientState(fetchItemsOriginal);
// ^ `state.pending` remains `false` during the delay
fetchItems({ delay: 500 }) // in millisecondsAllow the trackable action to reject explicitly with { throws: true } as the last parameter, along with exposing state.error returned from useTransientState that goes by default.
const [state, fetchItems] = useTransientState(fetchItemsOriginal);
fetchItems({ throws: true }).catch(handleError)<TransientStateProvider> creates an isolated instance of initial shared async action state. Its prime use cases are SSR and tests. It isn't required with client-side rendering, but it can be used to separate action states of larger self-contained portions of an app.
import { TransientStateProvider } from "react-statepack";
<TransientStateProvider>
<App/>
</TransientStateProvider>Use the provider to set up a specific initial async action state when required:
const initialState = {
"fetch-items": { initial: false, pending: true },
};
<TransientStateProvider value={initialState}>
<App/>
</TransientStateProvider>⬥ With an explicit value or without, the <TransientStateProvider>'s nested components will only respond to updates in the particular action state they subscribed to by means of useTransientState.
Shared state
Routing
- URL-based rendering
- Type-safe URL-based rendering
- Routing middleware
- URL parameters as state
- Type-safe URL parameters as state
- Type-safe nested routes
- Unknown routes
- Lazy routes
- View transitions
- Custom routing based on text input
- Converting links in HTML content to SPA links
Async action state
Find also the code of these live demos in the repo's tests directory.