DEV Community

loading...
Cover image for Build a react-router clone from scratch

Build a react-router clone from scratch

thomascullen profile image Thomas Cullen Updated on ・4 min read

React router is a package that I use in almost all of my projects. Not too long
ago, Michael Jackson tweeted this. This made me curious as to how difficult it would be to rebuild react router from scratch.

Before we dig into this I want to clarify that if you need a router in your project you should just use react router. It has a lot more features, handles a lot more edge cases and is incredibly well tested. This is purely a learning exercise.

In this post we are going to build a simplified version of react-router that is based on the newer v6 API's.

At the heart of react router is another package called 'history'. This package is responsible for managing the router history. For this post we are only concerned about creating a router for the web and so we are going to bake this directly into our react components. The first thing we are going to need is a root Router component and a context for our other components to consume. Let's start with the context.

Our router is going to be much more simplified than react router in that we aren't going to provide support for location state, hashes and other cases that react router provides. Our router context is going to provide 2 keys; location and push:

  • location is simply a string of the current path.
  • push is a function which can be called to change the current path.

And with that we can create our basic router context.

const RouterContext = React.createContext({
  location: "",
  push: () => {},
});
Enter fullscreen mode Exit fullscreen mode

This context is useless without rendering a provider. We are going to do that inside of our main Router component. The responsibility of this component is to provide information about the current route and provide ways to manipulate it. We are going to store the current location path in react state. This way when we update the location our component will re-render. We also need to provide the push function to our context which will simply update the browser location and update our location state. Finally we also listen for the window 'popstate' event to update our location when using the browser navigation buttons.

function Router({ children }) {
  const [location, setLocation] = React.useState(window.location.pathname);

  const handlePush = useCallback(
    (newLocation) => {
      window.history.pushState({}, "", newLocation);
      setLocation(newLocation);
    },
    []
  );

  const handleHashChange = useCallback(() => {
    setLocation(window.location.pathname);
  }, []);

  useEffect(() => {
    window.addEventListener("popstate", handleHashChange);
    return () => window.removeEventListener("popstate", handleHashChange);
  }, [handleHashChange]);

  const value = useMemo(() => {
    return { location, push: handlePush }
  }, [location, handlePush])

  return (
    <RouterContext.Provider value={value}>
      {children}
    </RouterContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

In order to test our component we are going to need a way to update the current route to check the correct components are rendering. Let's create a Link component for this. Our link component will simply take a to argument of the new path and call our push function from the router context when clicked.

function Link({ to, children }) {
  const { push } = React.useContext(RouterContext);

  function handleClick(e) {
    e.preventDefault();
    push(to);
  }

  return (
    <a href={to} onClick={handleClick}>
      {children}
    </a>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a way to navigate around, we need a way to actually render some routes! Let's create a Routes and Route component to handle this. Let's start with the Route component because all it needs to do is simply render the children we give it.

function Route({ children }) {
  return children;
}
Enter fullscreen mode Exit fullscreen mode

Next we need our Routes component. Here we need to iterate through the route components and find one that matches the current location. We will also want to render the matched route inside of a route context, so that our route children can access any params that matched in the path. Let's start by creating the functions we need to match the routes. The first thing we need is a function that takes the path prop on a route and converts it into a regex that we can use to match against the current location.

function compilePath(path) {
  const keys = [];

  path = path.replace(/:(\w+)/g, (_, key) => {
    keys.push(key);
    return "([^\\/]+)";
  });

  const source = `^(${path})`;

  const regex = new RegExp(source, "i");
  return { regex, keys };
}
Enter fullscreen mode Exit fullscreen mode

This will also give us an array of any keys that represet any params in the path pattern.

compilePath("/posts/:id");
// => { regex: /^(/posts/([^\/]+))/i, keys: ["id"] }
Enter fullscreen mode Exit fullscreen mode

Next up we need a new function that will iterate through each child route and use the compilePath function to test if it matches the current location, while also extracing any matching params.

function matchRoutes(children, location) {
  const matches = [];

  React.Children.forEach(children, (route) => {
    const { regex, keys } = compilePath(route.props.path);
    const match = location.match(regex);

    if (match) {
      const params = match.slice(2);
      matches.push({
        route: route.props.children,
        params: keys.reduce((collection, param, index) => {
          collection[param] = params[index];
          return collection;
        }, {}),
      });
    }
  });

  return matches[0];
}
Enter fullscreen mode Exit fullscreen mode

Finally we can create a new RouteContext and put together our Routes component. We'll pass the provided children into the matchRoutes function to find a matching route and render it inside of a provider for the route context.

const RouteContext = React.createContext({
  params: {},
});

function Routes({ children }) {
  const { location } = useContext(RouterContext);
  const match = useMemo(() => matchRoutes(children, location), [
    children,
    location,
  ]);

  const value = useMemo(() => {
    return { params: match.params }
  }, [match])

  // if no routes matched then render null
  if (!match) return null;

  return (
    <RouteContext.Provider value={value}>
      {match.route}
    </RouteContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

At this point we actually have a functioning router, however, we are missing a small but crucial piece. Every good router needs a way to extract parameters from the URL. Thanks to our RouteContext we can easily create a useParams hook that our routes can use to extract this.

function useParams() {
  return useContext(RouteContext).params;
}
Enter fullscreen mode Exit fullscreen mode

And with all of that we have our own basic working version of react router!

function Products() {
  return (
    <>
      <h4>Example Products</h4>
      <ul>
        <li>
          <Link to="/products/1">Product One</Link>
        </li>
        <li>
          <Link to="/products/2">Product Two</Link>
        </li>
      </ul>
    </>
  );
}

function Product() {
  const { id } = useParams();
  return (
    <>
      <h4>Viewing product {id}</h4>
      <Link to="/">Back to all products</Link>
    </>
  );
}

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/products/:id">
          <Product />
        </Route>
        <Route path="/">
          <Products />
        </Route>
      </Routes>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

Discussion (15)

pic
Editor guide
Collapse
harshilparmar profile image
Harshil Parmar

Great Effort !! brother 😁
I don't have much experience with useCallback,
Can you please explain this
How callback will detect that setLocation is changed !!
setLocation is just setter funciton right.

const handleHashChange = useCallback(() => {
    setLocation(window.location.pathname);
  }, [setLocation]);
Enter fullscreen mode Exit fullscreen mode
Collapse
adnanaslam profile image
Adnan Aslam

useCallback() hook is used to capture the whole function just a way to optimize the performance like as memoisation.

useCallback() used in conjunction with useEffect() because it allows you to prevent the re-creation of a function.

I hope this information helps you in understanding the useCallback() hook in react.

@Cheers
adnanaslam

Collapse
harshilparmar profile image
Harshil Parmar

Thanks brother!!

Collapse
thomascullen profile image
Thomas Cullen Author

Thanks for pointing this out! You are correct! We don't need to pass setLocation here in the callback dependencies as it won't change. Will update the post now, thank you!

Collapse
evgenyartemov profile image
Evgeny Artemov

Great content and so few likes! That's because it's hard to understand in 5 mins :)

Collapse
thomascullen profile image
Thomas Cullen Author

Thanks! Yeah definitely agree it's a lot for a quick read. I am thinking about also accompanying the post with a video tutorial which might be easier to digest.

Collapse
dgiulian profile image
Diego Giuliani

Great post. I always wondered how React Routed worked internally, but never dived into the code. This article made it much clear to me.

Thanks

Collapse
thomascullen profile image
Thomas Cullen Author

Great to hear! Thanks for the comment.

Collapse
eecolor profile image
EECOLOR

Great article!

I noticed a common ‘mistake’ in your code. I wrote about it here: medium.com/@ewestra/great-article-...

Collapse
thomascullen profile image
Thomas Cullen Author

Great feedback! I'l be sure to update the post. Thank's for the comment!

Collapse
sadiosan profile image
Se Ankh Ka Rê

Nice work. Thank's

Collapse
jagrati16 profile image
jagrati16

Great Post !

Can you please explain where params in params[index] is defined?

Thanks.

Collapse
thomascullen profile image
Thomas Cullen Author • Edited

🤦‍♂️ I was missing a line. Sorry about that! Have updated the post now. Thanks for the comment!

Collapse
brunowmoreno profile image
Bruno Queiroz

Awesome man, thanks a lot!

Collapse
thomascullen profile image
Thomas Cullen Author

You're welcome! Glad you liked the read :) Hoping to do similar posts for other popular packages.