React/history middleware libraries are discouraged, better to use fp-ts-routing and the native browser history api
(also integrates well with redux-observable: fromEvent(window, 'popstate')
)
An HOC that builds a router that represents the current route in react state as an ADT (another more in depth explanation of ADTs here) and safely manages an interceptable.
Alternative to react-router-dom
Thanks to Giulio Canti for fp-ts and fp-ts-routing. Thanks React Training for history
.
yarn add react-fp-ts-router
Check out the fp-ts-routing docs for more info on parsers and formatters.
These examples use unionize
for their route ADTs, but you could use simple union types and type guards, or you could use the fp-ts-codegen playground to easily generate ADTs and their associated functions.
If your app has no state that depends on or affects the way the current route changes, you can use withStaticRouter
. Be advised, however, that if routing anti-patterns start to creep into your app, you should use withInterceptingRouter
and an interceptable
instead.
This example creates a web app with the following rules:
- At the '/' route, it renders a 'show' button that reroutes to '/show'
- At the '/show' route, it renders a 'hide' button that reroutes to '/'.
- At a route it doesn't recognize, it behaves as though it's at the '/' route.
This example uses a simple optional string
as its interceptable
. This interceptable
will be set differently depending on how the app's route is changed.
It creates a version of the above example, with the following additional rules:
- it displays an
interceptable
at '/show' - the
interceptable
is set to 'from button click' when routed to '/show' from the 'show' button - the
interceptable
is set to 'from route' when routed to '/show' directly from the browser - it redirects any unrecognized route to '/'
An interceptable
models state that depends on or affects the way the current route changes.
An interceptable
is used to de-couple routing logic from the component lifecycle.
Here are common routing anti-patterns that appear when using withStaticRouter
, and alternative solutions that use interceptable
and withInterceptingRouter
.
If you're using withStaticRouter
and you find yourself doing a stateful redirect like this:
// Redirector.tsx
componentDidMount() {
navigate(N.push(MyRouteADT.badRoute()));
}
render() {
return null;
}
// in parent component
{route === MyRouteADT.goodRoute() && (
data.type === 'bad'
? (
<Redirector />
)
: (
<Comp
data={data.good}
/>
)
)}
You might be frustrated that Redirector
is forced to implement render
. You might also recognize this as UNSAFE_componentWillReceiveProps
in disguise.
You should use withInterceptingRouter
instead, and move data
into your interceptable
:
const interceptRoute = (
route: MyRouteADT,
interceptable: MyInterceptable,
): InterceptRouteResponse<MyRouteADT, MyInterceptable> => {
if (route === 'goodRoute' && interceptable.data.type === 'bad') {
return {
sync: {
redirect: Navigate.push(RouteADT.badRoute()),
}
}
}
}
// in parent component
{route === 'goodRoute' && interceptable.data.type !== 'bad' (
<Comp
goodData={data.good}
/>
)}
If you're using withStaticRouter
and you find yourself initializing data after a reroute like this:
// HandleDataInitialization.tsx
state = { data: undefined };
componentDidMount() {
if (this.props.route === RouteADT.loadedRoute()) {
// this.state.data can't be initialized yet,
// because this.state.data is always
// undefined on componentDidMount(), so
// redirect to RouteADT.loadingRoute()
navigate(N.push(RouteADT.loadingRoute()));
}
// runInitialize() will only be invoked once, even
// after a redirect from RouteADT.loadedRoute(),
// because this component is rendered at both
// RouteADT.loadingRoute() and RouteADT.loadedRoute()
// so a redirect will not trigger a new componentDidMount()
const runInitialize = T.task.map(initializeData(), data => {
this.setState({ data });
navigate(N.push(RouteADT.loadedRoute()));
});
runInitialize();
}
render(){
if (this.state.data === undefined) return (
<LoadingSpinner />
);
return (
<DataComp
data={this.state.data}
/>
);
}
// in parent component
{ (route === RouteADT.loadedRoute() || route === RouteADT.loadingRoute()) && (
<HandleDataInitialization />
)}
You should be appalled at your impenetrable routing logic. You might also recognize this as UNSAFE_componentWillReceiveProps
in an even sneakier disguise.
You should use withInterceptingRouter
instead, and move data
into your interceptable
:
const interceptRoute = (
route: MyRoute,
interceptable: MyInterceptable,
): InterceptRouteResponse<MyRouteADT, MyInterceptable> => {
if (
route === RouteADT.loadedRoute()
&& interceptable.data === undefined
) {
return {
sync: {
redirect: N.push(RouteADT.loadingRoute()),
}
};
}
if (route === RouteADT.loadingRoute()) {
return {
async: T.task.map(initializeData(), data => ({
interceptable: {
...this.props.interceptable,
data,
}
redirect: N.push(RouteADT.loadedRoute())
})),
}
}
}
// in parent component
{(
route === routeADT.loadedRoute() || route === RouteADT.loadingRoute()
) && (
interceptable.data !== undefined
? <DataComp
data={interceptable.data}
/>
: <LoadingSpinner />
)}
Why does setInterceptable
return a Task<I>
? It's annoying that I have to remember to invoke it every time I use it
If you do this:
// in a component
<button onClick={() => {
const runReroutedData = T.task.map(
loadReroutedData(),
reroutedData => {
navigate(RouteADT.loadedRoute());
const runSetInterceptable = this.props.setInterceptable(reroutedData);
runSetInterceptable();
}
);
runReroutedData();
}}>reroute</button>
// in your interceptRoute
const interceptRoute = (
route: MyRouteADT,
interceptable: MyInterceptable
): InterceptRouteResponse<MyRouteADT, MyInterceptable> => {
if (route === RouteADT.loadedRoute() && interceptable === undefined) {
return {
async: T.task.map(loadInitializedData, initializedData => {
return {
interceptable: initializedData,
}
}),
}
}
}
You may be surprised that clicking reroute
causes interceptable to be set to initializedData
.
This is because navigate
triggers interceptRoute
before setInterceptable
can enqueue changes to interceptable
. This causes interceptRoute
to think that interceptable
is uninitialized, which will trigger a Task
that returns initializedData
, which will clobber reroutedData
.
You should do this instead:
<button onClick={() => {
const runReroutedData = pipe(
preLoadData(),
T.chain(this.props.setInterceptable),
T.map(() => navigate(N.push(RouteADT.route()))),
);
runReroutedData();
}}>load stuff</button>
The Task
returned by setInterceptable
uses a setState
callback to ensure interceptable
is updated before it resolves. It resolves into the latest interceptable
state.
While it may be annoying to have to invoke this task every time you want to use setInterceptable
, it forces you to consider its runtime asynchronicity at compile time. As we have seen, it can be dangerous to think of setInterceptable
as synchronous in relation to navigation.
You can, but you shouldn't. React offers no way to enforce this at compile time, but if react-fp-ts-router
could prevent multiple router components or multiple instances of router components, it would. withInterceptingRouter
is an HOC only because its parameters are constants, so passing them in through props wouldn't make sense. It's not meant to create a reusable component.
The route
prop provided to the router is meant to be the single source of truth of the browser's current route.
Isn't it cumbersome to drill the current route through all of my components's props?
While you are encouraged to use react context to avoid drilling setInterceptable
, drilling route
is actually a feature.
A good practice with this library is to nest several routing ADTs together to mirror your app's component tree hierarchy. This enables you to ensure the correctness of your render logic at compile time. In this example, we see that the LoggedIn
and LoggedOut
components are relieved of the responsibility of handling irrelevant routes. This is one advantage we gain by having the current route represented globally.
type AppRoute = {
type: 'loggedIn';
loggedInRoute: LoggedInRoute;
} | {
type: 'loggedOut';
loggedOutRoute: LoggedOutRoute;
}
type LoggedInRoute = ...;
type LoggedOutRoute = ...;
const Root = ({ route }: { route: AppRoute }) => {
if (route.type === 'loggedIn') {
return (
<LoggedIn
loggedInRoute={route.loggedInRoute}
/>
)
}
return {
return (
<LoggedOut
loggedOutRoute={route.loggedOutRoute}
/>
);
}
}
At first, this seems unintuitive. One of the advantages of React is that it allows state to be distributed across many components. This minimizes re-renders and localizes interrelated data within the nodes of a deeply nested tree. All of these advantages seem lost when state is consolidated in your topmost component.
However, interceptable
is tightly coupled to the current route because, by definition, it depends on or affects the way the current route changes. Since the current route is global, interceptable must also be global.
On closer analysis, this makes sense. interceptRoute
must handle any incoming route, so it wouldn't make sense to have multiple interceptRoute
s that handled different interceptable
s because they would have overlapping domains.
For these reasons listed above, interceptable should be minimal. State unrelated to the current route should be handled elsewhere.
Use shouldComponentUpdate
to prevent unwanted re-renders, or its function component analog, React.memo
:
const Memoized = React.memo(
MyComponent,
// returning true prevents a re-render
(prevProps, nextProps) => prevProps.id === nextProps.id
)
react-fp-ts-routing
provides a helper function called reactMemoEq
that wraps React.memo
, using an Eq
to compare props.
import * as E from 'fp-ts/lib/Eq';
import { reactMemoEq } from 'react-fp-ts-router';
interface InnerCompProps { text: string, num: number }
const InnerComp = ({ text, num }: InnerCompProps) => (
<div> text: {data} num: {num} <div/>
)
const Memoized = reactMemoEq(
Inner,
E.getStructEq<InnerCompProps>({
text: E.eqString,
num: E.eqNum,
}),
)
const Landing = ({ interceptable }) => (
<Memoized
text={interceptable.text}
num={interceptable.num}
/>
);
Optics can help you transform deeply nested interceptable:
import * as M from 'monocle-ts';
import * as T from 'fp-ts/lib/Task';
import { pipe } from 'fp-ts/lib/pipeable';
interface MyInterceptable {
user: {
memories: {
childhood: {
favoriteColor?: string;
}
}
}
}
const favoriteColorLens = M.Lens.fromPath<MyInterceptable>()([
'user', 'memories', 'childhood', 'favoriteColor',
]);
const interceptRoute = (route: R, interceptable: MyInterceptable) => {
if (
route.type === 'favoriteColorRoute'
&& favoriteColorLens.get(interceptable) === undefined
) {
return {
async: {
interceptable: pipe(
loadFavoriteColor(),
T.map(
(favoriteColor) => favoriteColorLens.set(favoriteColor)(interceptable)
)
)
},
}
}
};
createNavigator
creates a function that you can export and use anywhere in your app to reroute using the provided routing ADT. Internally, withInterceptingRouter
uses createNavigator
for redirects.
import { Navigation } from 'react-fp-ts-routing';
export function createNavigator <R>(
formatter: ((r: R) => string),
): (navigation: Navigation<R>) => void
Type Variable | Description |
---|---|
R |
Routing ADT type |
Param | Description |
---|---|
formatter |
Converts routing ADT into a url path string |
withStaticRouter
HOC
The Router
component that withInterceptingRouter
wraps is given the props SimpleRouterProps<R>
:
interface SimpleRouterProps<R> {
route: R;
}
Type Variable | Description |
---|---|
R |
Routing ADT type |
Prop | Description |
---|---|
route |
Your app's current route, represented as your routing ADT |
import { Parser } from 'fp-ts-routing'
import * as History from 'history'
function withStaticRouter<R, T extends {} = {}>(
Router: React.ComponentType<T & SimpleRouterProps<R>>,
parser: Parser<R>,
formatter: ((r: R) => string),
notFoundRoute: R,
): React.ComponentType<T>
Type Variable | Description |
---|---|
R |
Routing ADT type |
T |
Other arbitrary props passed into Router , defaults to the empty object |
Param | Description |
---|---|
Router |
Your app's router component |
parser |
Converts url path strings into routing ADT |
notFoundRoute |
ADT to use when parser can't find a route |
withInterceptingRouter
HOC
The Router
component that withInterceptingRouter
wraps is given the props RouterProps<S, R>
:
import * as N from 'react-fp-ts-routing/lib/Navigation';
export interface InterceptingRouterProps<R, I> {
route: R;
interceptable: I;
setInterceptable: SetInterceptable<I>;
}
export type SetInterceptable<I> = (newInterceptable?: I) => T.Task<void>;
Type Variable | Description |
---|---|
R |
Routing ADT type |
I |
interceptable type |
Prop | Description |
---|---|
interceptable |
Your router's interceptable |
route |
Your app's current route, represented as your routing ADT |
updateRouter |
Optionally updates interceptable and then optionally invokes a Navigation |
import { Parser } from 'fp-ts-routing'
import { Navigation, Action } from 'react-fp-ts-routing';
import * as History from 'history'
type InterceptRoute<R, I> = (
newRoute: R,
interceptable: I,
oldRoute: R,
Action: Action,
) => InterceptRouteResponse<R, I>;
interface InterceptRouteResponse<R, I> {
sync?: Interception<R, I>;
async?: T.Task<Interception<R, I>>;
}
interface Interception<R, I> {
interceptable?: I;
redirect?: Navigation<R>;
}
function withInterceptingRouter<S, R, T extends {} = {}>(
Router: React.ComponentType<T & ManagedStateRouterProps<S, R>>,
parser: Parser<R>,
formatter: ((r: R) => string),
notFoundRoute: R,
defaultManagedState: S,
interceptRoute?: interceptRoute<S, R>,
): React.ComponentType<T>
Type Variable | Description |
---|---|
R |
Routing ADT type |
I |
interceptable type |
T |
Other arbitrary props passed into Router , defaults to the empty object |
Param | Description |
---|---|
Router |
Your app's router component |
parser |
Converts url path strings into routing ADT |
formatter |
Converts routing ADT into a url path string |
notFoundRoute |
ADT to use when parser can't find a route |
defaultinterceptable |
Populates interceptable before component is mounted |
interceptRoute |
Updates the router using the new route and preexisting interceptable |
Navigation
is an ADT representing the different possible history navigations
.
Navigation<R> Type |
Description |
---|---|
Navigation.push(route: R) |
Reroutes to routing ADT R |
Navigation.replace(route: R) |
Reroutes to routing ADT R without pushing new entry onto the history stack, so the browser's back button won't be able to go back to original location |
Navigation.pushExt(path: string) |
N.push that can reroute to somewhere outside of your app |
Navigation.replaceExt(path: string) |
N.replace that can reroute to somewhere outside of your app |
Navigation.go(delta: number) |
Moves delta number of times through the session history stack. Can be positive or negative. |
Navigation.goBack |
Moves back one page in the history stack |
Navigation.goForward |
Moves forward one pack in the history stack |
Action
is an ADT representing the different possible ways the browser arrived at its current location.
Action Type |
Description |
---|---|
Action.push |
The url was pushed onto the stack. (The user clicked a link, or your app used N.push ) |
Action.pop |
The url was popped from the stack. (The user hit the browser's back button, or your app used N.go or N.goBack ) |
Action.replace |
The url replaced the top entry of the stack. (Your app used N.replace ) |
- use
window.history
and WindowEventHandlers instead ofhistory