Decoupled navigation request handling
May 2023, by Maarten Nieber
Processing navigation requests

Introduction

In the last section we saw how we can define routes using route functions. Let's now consider url updates. I will first describe the standard way of triggering url changes, and then describe a more decoupled approach that is based on a navigation context and navigation functions.

How url changes are usually triggered

The easiest way to trigger url changes in components is to call history.push inside of the component. However, this limits reuse. A better way is to pass a navigation function into the component (as a property) that takes care of the navigation. This allows us to decide the desired navigation on a higher level (usually a parent component). Still, this might not be the best way to achieve decoupling, since it requires (potentially multiple levels of) prop drilling. Moreover, it bothers me to delegate the responsibility for setting up the navigation handling to components. Intuitively, I would rather use the exception handling model for navigation, where navigation requests are like exceptions that bubble up until - at some level - a navigation handler processes them.

A decoupled approach for triggering url changes

To illustrate the approach that is based on the exception model, consider an application that shows blog-posts. The application has a /posts/:postSlug page that contains a BlogPostlist. The application uses navigation handlers (implemented by React contexts) to provide navigation functions to the rendering components. The BlogPostList can make a navigation request as follows:

  1. It calls const navContext = useNavContext("BlogPostList") to obtain a navigation context that contains all available navigation handlers.
  2. It calls navContext.nav(toPost)(postSlug);
  3. The navigation context loops over its navigation handlers. It finds the handler that can handle the request, and returns the corresponding navigation function.
  4. BlogPostList executes this navigation function with the postSlug argument to update the url to /posts/:postSlug.

Now suppose that the application also has a page with archived blog-posts. This page uses a BlogPostList that is wrapped in a ArchivedPostsNavHandler. In this case, the same navigation request from BlogPostList will be handled by a navigation function that updates the url to /archived-posts/:postSlug. Note that BlogPostList can remain agnostic of how the url must be changed.

PostListView.tsx
posts/navFunctions.ts
import { toPost } from '/src/posts/navFunctions';
import { useNavContext } from 'react-nav-handler';

export type PropsT = { /* omitted for brevity */ };

export const PostListView = observer((props: PropsT) => {
  const navContext = useNavContext("PostListView");
  const navToPost = navContext.nav(toPost);
  const postDivs = props.posts.map((post, idx) => {
    return (
      <PostListViewItem
        key={`item-${post.id}`}
        post={post}
        onClick={(e: any) => navToPost(post.slug)}
      />
    );
  });
  return (
    <div className={cn('PostListView', props.className)}>
      {postDivs}
    </div>
  );
});

Declaring and installing navigation handlers

To make the above code work, we will define a PostsNavHandler and a ArchivedPostsNavHandler that both implement the toPost navigation function. Then, we will add them to the UrlRouter. PostsNavHandler will be mounted when the user visits the /posts page, and ArchivedPostsNavHandler when the user visits /archive.

PostsNavHandler.ts
ArchivedPostsNavHandler.ts
UrlRouter.tsx
import { NavHandlersProvider } from '/src/navHandler';

export const createNavFunctionTable = () => {
  return {
    toPost: ((navContext: NavContextT) => (postId: string) => {
      const url = getRouteFns<PostsRoutesT>().post({ postId });
      return { url, nav: () => history.push(url) };
    }) as typeof toPost,
  };
};

export const PostsNavHandler = (props: React.PropsWithChildren<{}>) => {
  const handler = useBuilder(() => ({
    id: 'PostsNavHandler',
    navFunctionTable: createNavFunctionTable(),
  });
  return (
    <NavHandlersProvider extend value={[handler]}>
      {props.children}
    </NavHandlersProvider>
  );
};

Simple navigation functions

Since context-dependent navigation functions require more boilerplate, I only use them when necessary. The navFunctions.ts file can also contain simple navigation functions that are not context-dependent:

// Import statements
// ...

export const toPost = createNavFunction(
  'toPost',
  (postSlug: string) => navTargetStub
);
// A simple navigation function
export const navToPostsHelp = () => history.push(routeFns.postsHelp());

If it later turns out that a context-dependent toPostsHelp function is needed, then a small refactoring can take care of that.

Fine-grained navigation

The navContext argument in the implementation of toPost is currently unused. We can use it in case that the navigation function should only handle requests with a particular requesterId:

export const createNavFunctionTable = () => {
  return {
    toPost: ((navContext: NavContext) =>
      navContext.requesterId === 'PostListView'
        ? (postId: string) => {
            const url = getRouteFns<PostsRoutesT>().post({ postId });
            return { url, nav: () => history.push(url) };
          }
        : undefined) as typeof toPost,
  };
};

Another possiblity is to only handle the navigation requests if we are on a particular url. These are advanced use-cases, and you probably won't need them, but in rare cases they may be useful.