August 11, 2020Handling promises in Sagas
August 11, 2020Modern Frontend Architecture Part 1: API
May 5, 2020Frontend Pattern: Route Object
May 1, 2020Frontend skills for the Web Designer
April 10, 2020Learning Frontend Path
January 26, 2020Podcasts I listen to (Jan 2020)
January 23, 2020Are you too old to start programming
January 22, 2020Use tabs not spaces
October 18, 2019Refactoring Overgrown React Components slides
October 2, 2019Book Review: Factfulness
October 1, 2019Yes, it's a good time to add TypeScript to your project
September 24, 2019Code Review – Best Practices, Guidelines & Process Insights
June 10, 2019Why I read on the iPad
March 18, 2019Software & Hardware I use
March 16, 2019Why I dont use React Router Link component
November 15, 2018Why public money software is not open source?
July 5, 2018How learning Angular made me better React developer
May 23, 2018My 12 tips how to increase your frontend coding productivity

rr

Why I dont use React Router Link component

The Link Component in React Router (on other frontend routers) is a popular, declarative pattern for navigating app in the view. In my opinion it's the ani-pattern.

Component <Link to"""/> is familiar to everyone who at least once used React Router (and I guess any other Router?).

It is used instead native <a /> element to intercept browser behaviour and handle it client side via e.g History API (React Router 4 uses its extended implementation).

The alternative way for routing is using the history interface, most likely history.push('/some-url').

Based on documentation and most patterns we can see that <Link/> is the default way. Why? It's declarative, so good buzzword for React right? Under the hood it renders <a> element to maintain semantics and connects to RR. Of course often we need other way to navigate, so we use history imperative way. We access history from Router's context (withRouter, useHistory).

What is Link under the hood?

If we take a look into the source code, we see that the <Link> component is nothing else than wrapper <a> element, which takes history from the context and calls .push() method with to= prop.

So we can conclude that Link is nothing more than declarative wrapper on history API. And its the great power of React!

But think about it again - <Link> is directly calling context which is provided with <BrowserRouter> (or another <Router>). I'm pretty sure you faced error during testing that you rendered <Link> outside Router context (the same for <NavLink> or <Route>).

Link is tight coupling router and the view

Whole React's architecture and it's good practices rely on maximizing using presentational component, which receive minimum required props and focus on one thing - rendering some piece of view.

We rely on Single Responsibility Principle here. I personally think it's the most important rule in programming itself.

The common case of using links is navigation. Let's create some example presentational component of navigation - without routing for now:

interface ILink extends HTMLAttributes<HTMLAnchorElement> {}

interface INavigationProps {
	links: ILink[];
}

const Navigation = ({ links }: INavigationProps) => (
	<nav>
		{links.map((link) => (
			<a {...link} />
		))}
	</nav>
);

This component receives collection of links which extends attributes from native <a> element. It has one single responsibility (however its not deterministic anyway, because of how routing works).

To connect it with React Router, it's only required to change:

<a {...link} />

to

<Link to={link.href} {...link} />

And now we end up with the problem - we wanted simple, presentational component, but de facto clicking link causes side effects - depending on global context, router config etc. clicking link can cause changing url, but also an error or 404 page. We don't know, and it's the problem

And by the way we created tight coupling. Navigation is not modular, we can't use it without this particular router implementation (unless we provide the same interface). And what's worse we can't test it anymore because we have to mock Router and do extra assertions to check if this UI works. It's not unit test anymore, we can't test simple display-and-emit-event model, we need to write complex integration tests with mocks we normally shouldn't need.

Separating abstractions

Given these problems I prefer to separate presentation and routing abstractions.

In presentation layer we will provided required links, but instead of redirecting, component will only emit events that some link was clicked by user. So modified Navigation example will look like this:

interface ILink extends HTMLAttributes<HTMLAnchorElement> {}

interface INavigationProps {
	links: ILink[];
	onNavigate: (to: string) => any;
}

export const Navigation = ({ links, onNavigate }: INavigationProps) => {
	const onClick = (e: MouseEvent<HTMLAnchorElement>, path: string) => {
		e.preventDefault();

		onNavigate(path);
	};

	return (
		<nav>
			{links.map((link) => (
				<a {...link} onClick={(e) => onClick(e, link.href)} />
			))}
		</nav>
	);
};

As you can see I added universal onNavigate handler and events are blocked with event.preventDefault(). This way navigation doesn't work, but it also doesn't have any external dependencies. It's easy to test and move.

Now the routing abstraction:

export const withNavRouting = (
	Component: React.ComponentType<INavigationProps & RouteComponentProps>
) => ({ onNavigate, history, ...props }: INavigationProps) => {
	const handleNavigate = (path: string) => {
		history.push(path);
	};

	return <Component onNavigate={handleNavigate} {...props} />;
};

export const NavigationWithRouter = compose(
	withRouter, // HOC from react-router-dom
	withNavRouting
)(Navigation);

We can see few steps here:

  1. We create HOC which takes the same props as Navigation component and extend it with RouteComponentProps - so this HOC will have to be used together with withRouter HOC which provides these props.

  2. We extract from props callback onNavigate and history and left rest of ...props untouched.

  3. We render <Component> where we pass onNavigate={handleNavigate}, which is defined in our HOC and we pass rest of props.

  4. Our handleNavigate receives from component path from <a href> element.

  5. In the end we compose two HOCs together = withRouter which takes history from context and our withNavRouting, in the end they wrap presentational component.

Now we have clear abstractions separation.

  1. Navigation - clear component responsible only of the view.
  2. withNavRouting - HOC which is responsible of handling events from the view and connecting it with router.
  3. NavigationWithRouter - composed component made from the view and HOC which can live independently.

Why?

Why is it worth to add this extra work here?

Testability

Each of these parts is easy to test. View doesn't require andy extra dependencies (Router, context). HOC can be be injected with mocked history so we can check if it works properly, without mocking browser. And composed, final component can be used in real integration tests to check if they work together from business perspective.

Compositions

All of these layers are just functions which we can compose easily. For example nav can work a little bit different when user is logged, so we can add extra HOC or replace withNavRouting to something like withAuthorizedRouting and have different behaviour. No ifs and huge components. And no modifying existing code to add new features.

Single Responsibility Principle

Being guided by SRP allows to write easily maintainable code, which is understandable. Each piece is simple because it does only one thing, so we reduce cognitive load required to read someone's (or ours in few months) code.

Following this path we can easily add new features and we don't need to touch any of existing pieces.

Loose coupling

Loose coupling is the effect of the previous points. Of course I will not argue that ability to change router to another one is the real business case - this kind of argument is noble but rarely useful. However the worst type of complexity and code smell are mixed dependencies - allowing us to separate them is a huge thing.

Into the rabbit hole - using Sagas

I love redux-saga and I like to experiment with even further decoupling following patterns like CQRS and DDD. Sagas allow you to separate business threads from the view and control it really elegant way.

Using sagas, instead of calling history inside withNavRouting we will dispatch event.

// File with actions
export const navLinkClickedAction = ({ path }: { path: string }) => ({
	type: 'NAV:LINK_CLICKED',
	payload: { path }
});

export const withNavRouting = (
	Component: React.ComponentType<INavigationProps>
) => ({ onNavigate, dispatch, ...props }: INavigationProps) => {
	const handleNavigate = (path: string) => {
		dispatch(navLinkClickedAction({ path }));
	};

	return <Component onNavigate={handleNavigate} {...props} />;
};

export const NavigationWithRouter = compose(
	connect(), // HOC from react-router
	withNavRouting
)(Navigation);

Now we go even further - we only emit global domain event (user clicked nav) and wait for our business code to listen and react on it. Simple solution will be Saga like this

import {put} from '@redux-saga/effects';
import {push} from 'connected-react-router'

function* handleNavClickedSaga(action: typeof ReturnType<navLinkClickedAction>) {
    // Just listen on view event and emit nav event for router
    yield put(push(action.payload.path));
}

In simple app you can say thay we created another layer of code with no benefit, and probably it's true. In simple app you can also use jQuery and you don't need SPA ;)

However if you have complex logic, like authorization, guards etc, you need to somewhere perform all required checks, like authorizing user, saving state to redux or local storage, calling api etc.

Do you want to do this in React component? You don't. You should rather do business logic in saga and view logic in components. So it can look like this:

import {put} from '@redux-saga/effects';
import {push} from 'connected-react-router'

function* handleNavClickedSaga(action: typeof ReturnType<navLinkClickedAction>) {
    const isAuthorized = yield call(checkUserAuthorized());

    if(isAuthorized){
        yield put(push(action.payload.path));
        yield call(saveLoginState())
    }else {
        yield put(navigationFailed('Not authorized'))
    }
}

Handling logic like this in saga is clean and powerful. It's very easy to handle side effects and async code without bloating the view.

Summary

Rules I mentioned can be easily adapted in other cases, not only router, not only React. They are just generic good programming practices. I focused on <Link> as a special case, because it's very popular and often used in official docs - but using it can be harmful.