Let's build a React Navigation clone

Applies for React Native Navigation too!

React Navigation is a great package that helps with kickstarting your applications navigation.

In production environments I would always chose to use something like this, but for learning purposes I like to build such packages myself. That way I can learn a lot about the underlying concepts and why things work the way they work.

Let's dive into it: We're going to build react (native) navigation from scratch.

Plus: We're doing it test driven!

The concepts

Route

A route is something that is identifiable by a string (path). We can navigate to a route by using the Router.

Router

The router keeps track of all the routes, displays the current route and has a history.

Query Parameters

Query Paramters are important to tell each route additional information. This can be as easy as the UserId or filter options for a specific view.

Writing the tests

You should strictly separate the view layer from any business layer. Keeping the view dumb, will enable us to test mainly the business logic via unit tests and have just some integration tests.

interface RouterOptions {
  routes: Route[];
  initialRoute: string;
}

class DefaultRouterOptions implements RouterOptions {
  routes: Route[] = [
    {
      path: "/",
      component: () => <Text>Hello World</Text>
    },
    {
      route: "/welcome",
      component: () => <Text>Welcome</Text>
    },
    {
      route: "/settings",
      component: () => <Text>Settings</Text>
    },
    {
      route: "/profile",
      component: () => {
        const params = useParams();
        return <Text>Profile: {params?.userId}</Text>;
      }
    },
  ];
  initialRoute = "/";
}

type RouteWithData = Route & {data: any};

class Router {
  private _history: Route[] = [];
  public route: RouteWithData = undefined;
  constructor(private _routes: Route[], initialRoute: Route) {
    this.currentRoute = initialRoute;
  }

  navigate(path: string, data: any) {
    this._history.push(currentRoute);
    this.currentRoute = {...this._routes.find(c => c.route === path), data};
  }
  back() {
    if (this._history.length === 1) return;
    const poppedElement = this._history.pop();
    this.currentElement = poppedElement;
  }
}

// Factory Method Pattern
const makeRouter = (optionOverride: Partial<RouterOptions> = {}) => {
  const options = {...new DefaultRouterOptions(), ...optionOverride};
  return new Router(options.routes, options.initialRoute);
};


describe("Router", () => {
  it("navigates to initial route", () => {
    const router = makeRouter({initialRoute: "/welcome");
    expect(router.route).toEqual("/welcome");
  });
  it("changes current route on navigation", () => {
    const router = makeRouter();

    router.navigate("/settings");
    expect(router.route).toEqual("/settings");
  });
  it("changes current route onBack", () => {
    const router = makeRouter();

    router.navigate("/settings");
    router.navigate("/profile");
    router.back();
    expect(router.route).toEqual("/profile");
  });
  it("keeps track of query params" () => {
    let userIdSpy = undefined;
    const router = makeRouter({
      routes: [
        {
            route: "/profile",
            component: () => {
              const params = useParams();
              return <Text>Profile: {params?.userId}</Text>;
            }
        }
      ],
    });
    router.navigate("/profile", {userId: "0"});
    router.navigate("/settings");
    router.back();
    expect(router.data).toEqual({userId: "0"});
  });
});

Building the react native router part

Note that we are leveraging the aspects of Reacts Context API and I assume that you have some knowledge about it. Otherwise feel free to pause here and check this video: https://www.youtube.com/watch?v=5LrDIWkK_Bc and come back.

In essence however, it's about a state that's available for all the elements within the context provider.

// this is not exported intentionally
const RouterContext = createContext<Router>(undefined as Router);

export const RouterProvider = (props: {routes: Route[], initialRoute: string, children: JSX.Element}) => {
  const router = useMemo(new Router(routes, initialRoute), []);
  return <RouterContext.Provider value={router}>
    {props.children}
</RouterContext.Provider>
}

export const useRouter = () => {
  return useContext(RouterContext);
}

Integrating it in our app

function Navbar() {
  const router = useRouter();
  return <div>
<p>Current route: {router.route}</p>
<button onClick={() => router.navigate('/profile', {userId: '123456'})}>

</div>
}

function App() {
  // define the RouterContextProvider here
  return <RouterContext initialRoute="/" routes={[
    {
      path: "/",
      component: () => <Text>Hello World</Text>
    },
    {
      route: "/profile",
      component: () => {
        const params = useParams();
        return <Text>Profile: {params?.userId}</Text>;
      }
    },
  ]}>
    <Navbar/>
  </RouterContext>
}

Conclusion

Building a react native router is not as hard as it looks at first. But although a minimal thing that "does the thing" in its most simplistic manner looks easy, the devil is in the details.

For production cases I would not recommend building it yourself and stick to react-native navigation.

Did you enjoy this post? Subscribe to the newsletter :)