· 8 min read
10 Common Pitfalls in React Remix and How to Avoid Them
A practical guide to the ten most common mistakes developers make with React Remix - with clear explanations, concrete code fixes, and best practices to save hours of debugging.
Introduction
React Remix is powerful: it gives you server-first data loading, built-in forms, great routing, and a clear separation of concerns. But that opinionated approach brings its own pitfalls. This post walks through 10 frequent mistakes Remix developers run into, shows how to detect them, and gives concrete, production-ready solutions and best practices.
If you want the canonical reference while you read, the Remix docs are excellent: https://remix.run/docs
1) Fetching data in components instead of using loaders
Problem
A common React habit is to fetch data with useEffect
inside components. In Remix, doing that loses server rendering, SEO benefits, and can create flashing/loading states users don’t need.
Why it hurts
- Slower first paint and worse SEO because the server doesn’t render the data.
- Duplicate fetches: server and client both request the same data in some setups.
Typical (wrong) pattern
// App UI component
export default function Users() {
const [users, setUsers] = React.useState(null);
React.useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers);
}, []);
if (!users) return <div>Loading...</div>;
return <UsersList users={users} />;
}
Correct: use a loader
// routes/users.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export const loader = async () => {
const users = await getUsersFromDb();
return json({ users });
};
export default function UsersRoute() {
const { users } = useLoaderData<typeof loader>();
return <UsersList users={users} />;
}
Best practices
- Put data fetching in
loader
so the server can render it. - Use
useLoaderData
to access it on the client. - Use
defer
for large payloads you can stream: https://remix.run/docs/en/main/route/deferred-data
2) Confusing loaders and actions (GET vs mutations)
Problem
Treating loader
like a place for modifications or using action
for reads. Loaders are for reading data; actions are for POST/PUT/DELETE style mutations.
Why it hurts
- Semantic mismatch makes caching, redirects, and intent handling wrong.
- Browser behavior and forms expect POST to go to an action.
Wrong example
// Doing DB write in loader - bad
export const loader = async ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('reset')) {
await resetSomethingInDb();
}
return null;
};
Correct: handle mutations in an action
export const action = async ({ request }) => {
const form = await request.formData();
if (form.get('_action') === 'reset') {
await resetSomethingInDb();
return redirect('/somewhere');
}
};
Best practices
- Keep reads in loaders and side-effectful requests in actions.
- Use forms or
fetcher
to call actions from the client: https://remix.run/docs/en/main/hooks/use-fetcher
3) Using browser-only APIs on the server (window / localStorage)
Problem
Remix does server rendering. Accessing window
, document
, localStorage
, or browser-only libraries inside loaders or top-level code will crash the server.
Symptoms
- Build-time or server runtime errors referencing
window is not defined
.
Example bug
// BAD: top-level access
const token = localStorage.getItem('token');
export const loader = async () => {
/* uses token */
};
Fixes
- Only access browser APIs inside client-only hooks like
useEffect
. - Guard code:
if (typeof window !== 'undefined') { ... }
.
Correct pattern
export default function Component() {
React.useEffect(() => {
const token = localStorage.getItem('token');
// use token for client-only behavior
}, []);
return <div />;
}
Best practices
- Keep authentication/authorization checks on the server using cookies/sessions in loaders.
- Use environment variables and secrets on the server only.
Remix docs: https://remix.run/docs/en/main/guides/env
4) Overusing client-side state for form submissions (not using Remix forms)
Problem
Remix has first-class support for HTML forms that integrate with actions. Re-implementing form submission with client fetches without reason adds complexity and duplicates logic.
Why it hurts
- You lose the progressive enhancement, server validation, and navigation behavior Remix provides.
Bad example
// Manual fetch instead of <Form>
function CreatePost() {
const [title, setTitle] = useState('');
const submit = async e => {
e.preventDefault();
await fetch('/posts', { method: 'POST', body: new FormData(e.target) });
};
return <form onSubmit={submit}>...</form>;
}
Better: use Remix