0.0.32 • Published 4 years ago

react-fp-ts-router v0.0.32

Weekly downloads
1
License
MIT
Repository
github
Last release
4 years ago

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.

Example code

Live site

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 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 '/'

Example code

Live site

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 VariableDescription
RRouting ADT type
ParamDescription
formatterConverts 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 VariableDescription
RRouting ADT type
PropDescription
routeYour 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 VariableDescription
RRouting ADT type
TOther arbitrary props passed into Router, defaults to the empty object
ParamDescription
RouterYour app's router component
parserConverts url path strings into routing ADT
notFoundRouteADT 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 VariableDescription
RRouting ADT type
Iinterceptable type
PropDescription
interceptableYour router's interceptable
routeYour app's current route, represented as your routing ADT
updateRouterOptionally 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 VariableDescription
RRouting ADT type
Iinterceptable type
TOther arbitrary props passed into Router, defaults to the empty object
ParamDescription
RouterYour app's router component
parserConverts url path strings into routing ADT
formatterConverts routing ADT into a url path string
notFoundRouteADT to use when parser can't find a route
defaultinterceptablePopulates interceptable before component is mounted
interceptRouteUpdates the router using the new route and preexisting interceptable

Internal ADTs

Navigation

Navigation is an ADT representing the different possible history navigations.

Navigation<R> TypeDescription
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.goBackMoves back one page in the history stack
Navigation.goForwardMoves 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 TypeDescription
Action.pushThe url was pushed onto the stack. (The user clicked a link, or your app used N.push)
Action.popThe url was popped from the stack. (The user hit the browser's back button, or your app used N.go or N.goBack)
Action.replaceThe url replaced the top entry of the stack. (Your app used N.replace)

TODO

0.0.32

4 years ago

0.0.31

4 years ago

0.0.30

4 years ago

0.0.29

4 years ago

0.0.28

4 years ago

0.0.25

4 years ago

0.0.26

4 years ago

0.0.27

4 years ago

0.0.24

4 years ago

0.0.23

4 years ago

0.0.22

4 years ago

0.0.21

4 years ago

0.0.20

4 years ago

0.0.19

4 years ago

0.0.18

4 years ago

0.0.17

4 years ago

0.0.16

4 years ago

0.0.15

4 years ago

0.0.14

4 years ago

0.0.10

4 years ago

0.0.11

4 years ago

0.0.12

4 years ago

0.0.13

4 years ago

0.0.9

4 years ago

0.0.8

4 years ago

0.0.5

4 years ago

0.0.7

4 years ago

0.0.6

4 years ago

0.0.4

4 years ago

0.0.3

4 years ago

0.0.2

4 years ago