State management in React typically involves maintaining a synchronized cache of server data on the client side. However, with Remix, most of the traditional caching solutions become redundant because of how it inherently handles data synchronization.
In a typical React context, when we refer to "state management", we're primarily discussing how we synchronize server state with the client. A more apt term could be "cache management" because the server is the source of truth and the client state is mostly functioning as a cache.
Popular caching solutions in React include:
In certain scenarios, using these libraries may be warranted. However, with Remix's unique server-focused approach, their utility becomes less prevalent. In fact, most Remix applications forgo them entirely.
As discussed in Fullstack Data Flow Remix seamlessly bridges the gap between the backend and frontend via mechanisms like loaders, actions, and forms with automatic synchronization through revalidation. This offers developers the ability to directly use server state within components without managing a cache, the network communication, or data revalidation, making most client-side caching redundant.
Here's why using typical React state patterns might be an antipattern in Remix:
Network-related State: If your React state is managing anything related to the network—such as data from loaders, pending form submissions, or navigational states—it's likely that you're managing state that Remix already manages:
useNavigation
: This hook gives you access to navigation.state
, navigation.formData
, navigation.location
, etc.useFetcher
: This facilitates interaction with fetcher.state
, fetcher.formData
, fetcher.data
etc.useLoaderData
: Access the data for a route.useActionData
: Access the data from the latest action.Storing Data in Remix: A lot of data that developers might be tempted to store in React state has a more natural home in Remix, such as:
Performance Considerations: At times, client state is leveraged to avoid redundant data fetching. With Remix, you can use the Cache-Control
headers within loader
s, allowing you to tap into the browser's native cache. However, this approach has its limitations and should be used judiciously. It's usually more beneficial to optimize backend queries or implement a server cache. This is because such changes benefit all users and do away with the need for individual browser caches.
As a developer transitioning to Remix, it's essential to recognize and embrace its inherent efficiencies rather than applying traditional React patterns. Remix offers a streamlined solution to state management leading to less code, fresh data, and no state synchronization bugs.
For examples on using Remix's internal state to manage network related state, refer to Pending UI.
Consider a UI that lets the user customize between list view or detail view. Your instinct might be to reach for React state:
export function List() {
const [view, setView] = React.useState("list");
return (
<div>
<div>
<button onClick={() => setView("list")}>
View as List
</button>
<button onClick={() => setView("details")}>
View with Details
</button>
</div>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
Now consider you want the URL to update when the user changes the view. Note the state synchronization:
import {
useNavigate,
useSearchParams,
} from "@remix-run/react";
export function List() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [view, setView] = React.useState(
searchParams.get("view") || "list"
);
return (
<div>
<div>
<button
onClick={() => {
setView("list");
navigate(`?view=list`);
}}
>
View as List
</button>
<button
onClick={() => {
setView("details");
navigate(`?view=details`);
}}
>
View with Details
</button>
</div>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
Instead of synchronizing state, you can simply read and set the state in the URL directly with boring old HTML forms.
import { Form, useSearchParams } from "@remix-run/react";
export function List() {
const [searchParams] = useSearchParams();
const view = searchParams.get("view") || "list";
return (
<div>
<Form>
<button name="view" value="list">
View as List
</button>
<button name="view" value="details">
View with Details
</button>
</Form>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
Consider a UI that toggles a sidebar's visibility. We have three ways to handle the state:
In this discussion, we'll break down the trade-offs associated with each method.
React state provides a simple solution for temporary state storage.
Pros:
Cons:
Implementation:
function Sidebar({ children }) {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? "Close" : "Open"}
</button>
<aside hidden={!isOpen}>{children}</aside>
</div>
);
}
To persist state beyond the component lifecycle, browser local storage is a step-up.
Pros:
Cons:
window
and localStorage
objects are not accessible during server-side rendering, so state must be initialized in the browser with an effect.Implementation:
function Sidebar({ children }) {
const [isOpen, setIsOpen] = React.useState(false);
// synchronize initially
useLayoutEffect(() => {
const isOpen = window.localStorage.getItem("sidebar");
setIsOpen(isOpen);
}, []);
// synchronize on change
useEffect(() => {
window.localStorage.setItem("sidebar", isOpen);
}, [isOpen]);
return (
<div>
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? "Close" : "Open"}
</button>
<aside hidden={!isOpen}>{children}</aside>
</div>
);
}
In this approach, state must be initialized within an effect. This is crucial to avoid complications during server-side rendering. Directly initializing the React state from localStorage
will cause errors since window.localStorage
is unavailable during server rendering. Furthermore, even if it were accessible, it wouldn't mirror the user's browser local storage.
function Sidebar() {
const [isOpen, setIsOpen] = React.useState(
// error: window is not defined
window.localStorage.getItem("sidebar")
);
// ...
}
By initializing the state within an effect, there's potential for a mismatch between the server-rendered state and the state stored in local storage. This discrepancy will lead to brief UI flickering shortly after the page renders and should be avoided.
Cookies offer a comprehensive solution for this use case. However, this method introduces added preliminary setup before making the state accessible within the component.
Pros:
Cons:
Implementation:
First we'll need to create a cookie object:
import { createCookie } from "@remix-run/node";
export const prefs = createCookie("prefs");
Next we set up the server action and loader to read and write the cookie:
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { prefs } from "./prefs-cookie";
// read the state from the cookie
export async function loader({
request,
}: LoaderFunctionArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie = (await prefs.parse(cookieHeader)) || {};
return json({ sidebarIsOpen: cookie.sidebarIsOpen });
}
// write the state to the cookie
export async function action({
request,
}: ActionFunctionArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie = (await prefs.parse(cookieHeader)) || {};
const formData = await request.formData();
const isOpen = formData.get("sidebar") === "open";
cookie.sidebarIsOpen = isOpen;
return json(isOpen, {
headers: {
"Set-Cookie": await prefs.serialize(cookie),
},
});
}
After the server code is set up, we can use the cookie state in our UI:
function Sidebar({ children }) {
const fetcher = useFetcher();
let { sidebarIsOpen } = useLoaderData<typeof loader>();
// use optimistic UI to immediately change the UI state
if (fetcher.formData?.has("sidebar")) {
sidebarIsOpen =
fetcher.formData.get("sidebar") === "open";
}
return (
<div>
<fetcher.Form method="post">
<button
name="sidebar"
value={sidebarIsOpen ? "closed" : "open"}
>
{sidebarIsOpen ? "Close" : "Open"}
</button>
</fetcher.Form>
<aside hidden={!sidebarIsOpen}>{children}</aside>
</div>
);
}
While this is certainly more code that touches more of the application to account for the network requests and responses, the UX is greatly improved. Additionally, state comes from a single source of truth without any state synchronization required.
In summary, each of the discussed methods offers a unique set of benefits and challenges:
None of these are wrong, but if you want to persist the state across visits, cookies offer the best user experience.
Client-side validation can augment the user experience, but similar enhancements can be achieved by leaning more towards server-side processing and letting it handle the complexities.
The following example illustrates the inherent complexities of managing network state, coordinating state from the server, and implementing validation redundantly on both the client and server sides. It's just for illustration, so forgive any obvious bugs or problems you find.
export function Signup() {
// A multitude of React State declarations
const [isSubmitting, setIsSubmitting] =
React.useState(false);
const [userName, setUserName] = React.useState("");
const [userNameError, setUserNameError] =
React.useState(null);
const [password, setPassword] = React.useState(null);
const [passwordError, setPasswordError] =
React.useState("");
// Replicating server-side logic in the client
function validateForm() {
setUserNameError(null);
setPasswordError(null);
const errors = validateSignupForm(userName, password);
if (errors) {
if (errors.userName) {
setUserNameError(errors.userName);
}
if (errors.password) {
setPasswordError(errors.password);
}
}
return Boolean(errors);
}
// Manual network interaction handling
async function handleSubmit() {
if (validateForm()) {
setSubmitting(true);
const res = await postJSON("/api/signup", {
userName,
password,
});
const json = await res.json();
setIsSubmitting(false);
// Server state synchronization to the client
if (json.errors) {
if (json.errors.userName) {
setUserNameError(json.errors.userName);
}
if (json.errors.password) {
setPasswordError(json.errors.password);
}
}
}
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
handleSubmit();
}}
>
<p>
<input
type="text"
name="username"
value={userName}
onChange={() => {
// Synchronizing form state for the fetch
setUserName(event.target.value);
}}
/>
{userNameError ? <i>{userNameError}</i> : null}
</p>
<p>
<input
type="password"
name="password"
onChange={(event) => {
// Synchronizing form state for the fetch
setPassword(event.target.value);
}}
/>
{passwordError ? <i>{passwordError}</i> : null}
</p>
<button disabled={isSubmitting} type="submit">
Sign Up
</button>
{isSubmitting ? <BusyIndicator /> : null}
</form>
);
}
The backend endpoint, /api/signup
, also performs validation and sends error feedback. Note that some essential validation, like detecting duplicate usernames, can only be done server-side using information the client doesn't have access to.
export async function signupHandler(request: Request) {
const errors = await validateSignupRequest(request);
if (errors) {
return json({ ok: false, errors: errors });
}
await signupUser(request);
return json({ ok: true, errors: null });
}
Now, let's contrast this with a Remix-based implementation. The action remains consistent, but the component is vastly simplified due to the direct utilization of server state via useActionData
, and leveraging the network state that Remix inherently manages.
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import {
useActionData,
useNavigation,
} from "@remix-run/react";
export async function action({
request,
}: ActionFunctionArgs) {
const errors = await validateSignupRequest(request);
if (errors) {
return json({ ok: false, errors: errors });
}
await signupUser(request);
return json({ ok: true, errors: null });
}
export function Signup() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
const userNameError = actionData?.errors?.userName;
const passwordError = actionData?.errors?.password;
const isSubmitting = navigation.formAction === "/signup";
return (
<Form method="post">
<p>
<input type="text" name="username" />
{userNameError ? <i>{userNameError}</i> : null}
</p>
<p>
<input type="password" name="password" />
{passwordError ? <i>{passwordError}</i> : null}
</p>
<button disabled={isSubmitting} type="submit">
Sign Up
</button>
{isSubmitting ? <BusyIndicator /> : null}
</Form>
);
}
The extensive state management from our previous example is distilled into just three code lines. We eliminate the necessity for React state, change event listeners, submit handlers, and state management libraries for such network interactions.
Direct access to the server state is made possible through useActionData
, and network state through useNavigation
(or useFetcher
).
As bonus party trick, the form is functional even before JavaScript loads. Instead of Remix managing the network operations, the default browser behaviors step in.
If you ever find yourself entangled in managing and synchronizing state for network operations, Remix likely offers a more elegant solution.