DEV Community

Cover image for Stop Writing Messy States: Why I Swapped useEffect for React Router Loaders
Peter Ogbonna
Peter Ogbonna

Posted on

Stop Writing Messy States: Why I Swapped useEffect for React Router Loaders

We have all been there... You create a new component, and the first thing you do is set up the trinity of data fetching state:

  1. loading
  2. error
  3. data

Then, you write the inevitable useEffect hook. It usually looks something like this:

const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  const fetchData = async () => {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error('Network response was not ok');
      const result = await response.json();
      setUser(result);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  };
  fetchData();
}, [url]);

if (loading) return <Spinner />;
if (error) return <ErrorMessage />
Enter fullscreen mode Exit fullscreen mode

The Problem:

  • Boilerplate: We just wrote 20 lines of code just to get data from A to B.
  • Render-then-Fetch: The component has to mount (render) first before the effect kicks in to start fetching. This causes a waterfall effect and slower perceived performance.
  • State Management Hell: You are manually managing loading and error states in every single component that needs data.

The Solution: React Router Loaders introduced in v6

React Router v6.4+ introduced a paradigm shift: Loaders

Loaders are a part of the Data APIs which lets you handle data fetching, actions/mutations and error handling directly in your route definitions

The idea is simple: Fetch-then-Render.

Instead of the component asking for data after it mounts, the Router fetches the data before it renders the component. If the data isn't ready, the component doesn't load. If there is an error, the Router handles it elsewhere.

Here is how to clean up your code in 3 steps:

It's assumed you're working on a react application with react-router installed

Step 1: Setup Router that supports Data APIs

This will involve reconfiguring the router setup see Picking a Router

//App.jsx

// From this
import {BrowserRouter, Routes, Route} from "react-router"
import UserProfile from "./user"

function App() {
  return (
   <BrowserRouter>
      <Routes>
        <Route path="/" element={<UserProfile />} />
      </Routes>
    </BrowserRouter>
  )
}

//To this
import {RouterProvider, createBrowserRouter} from "react-router"
import UserProfile from "/user"

const router = createBrowserRouter([
  {
    path: '/user',
    element: <UserProfile />
  }
])

function App() {
  return (
    <RouterProvider router={router} />
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Write a loader function

You define a function that fetches your data.

// utils.js
export const userLoader = async () => {
  const res = await fetch('https://api.example.com/user');

  if (!res.ok) {
    throw Error("Could not fetch user details");
  }

  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Connect it to the Route

In your Router configuration (using createBrowserRouter), you plug the loader directly into the route.

// App.jsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { userLoader } from './utils';
import UserProfile from './user';
import ErrorPage from './errorPage';

const router = createBrowserRouter([
  {
    path: '/user',
    element: <UserProfile />,
    loader: userLoader,       // <--- The magic happens here
    errorElement: <ErrorPage /> // <--- Handles errors automatically!
  }
]);

function App() {
  return <RouterProvider router={router} />;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Consume the Data

Now, look at how clean your component becomes. No useEffect, No useState for loading or errors. Just one hook: useLoaderData

// user.jsx
import { useLoaderData } from 'react-router';

export default function UserProfile() {
  const user = useLoaderData(); // Data is already here!

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the ErrorPage, you can get error message and handle error by grabbing the useRouteError hook from "react-router", implementation is similar to useLoaderData

Why this is Clean Code?

  1. Separation of Concerns: Your UI component is responsible for UI, not fetching logic. The fetching logic lives in the loader.

  2. Automatic Error Handling: If the API fails, React Router catches the error and renders the errorElement defined in the route. Hence, your component doesn't need to check if (error).

  3. Performance: The browser starts fetching the data as soon as the user clicks the link, in parallel with loading the code for the new page.

Conclusion

useEffect is a powerful tool but using it for data fetching in 2025 is often unnecessary complexity. By switching to Loaders, you delete code, prevent bugs, and make your application feel snappier.

Give your useEffect a break and let the Router do the heavy lifting.

Top comments (0)