react-fp-ts-router v0.0.32
react-fp-ts-router
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.
Installation
yarn add react-fp-ts-router
Usage
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.
withStaticRouter
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.
withInterceptingRouter example
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
interceptableat '/show' - the
interceptableis set to 'from button click' when routed to '/show' from the 'show' button - the
interceptableis set to 'from route' when routed to '/show' directly from the browser - it redirects any unrecognized route to '/'
What is an interceptable?
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.
When do I need an interceptable?
Here are common routing anti-patterns that appear when using withStaticRouter, and alternative solutions that use interceptable and withInterceptingRouter.
Stateful redirection
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}
/>
)}Loading data after a reroute
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 />
)}FAQ
Why does setInterceptable return a Task<I>? It's annoying that I have to remember to invoke it every time I use it
Loading data before a reroute
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.
Can I have more than one router in my app?
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}
/>
);
}
}Why is interceptable global?
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 interceptRoutes that handled different interceptables because they would have overlapping domains.
Minimizing re-renders functionally
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}
/>
);Transforming deeply nested state functionally
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)
)
)
},
}
}
};Docs
createNavigator
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.
createNavigator Function Type
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
withStaticRouter Output Prop Types
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 |
withStaticRouter Function Type
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
withInterceptingRouter Output Prop Types
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 |
withInterceptingRouter Function Type
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 |
Internal ADTs
Navigation
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
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) |
TODO
- use
window.historyand WindowEventHandlers instead ofhistory
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago