0.0.4 • Published 4 years ago

fp-ts-routing-redux v0.0.4

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

fp-ts-routing-redux

This library presents three differrent integrations of fp-ts-routing into redux. Listed in order of increasing purity, they are routeMiddleware, routeObservable, and routeStream. navigationMiddleware is the only provided integration for navigation

Philosophy

The goal of this library is to represent routes in state, in the purely functional spirit of fp-ts-routing

Redux is the purest state management system at the time of publication*

Additionally, Redux manages a single global state, and since the current route is a global value, this is a good fit

On top of that, Redux and fp-ts-routing use ADTs in a usefully composable way (RouteActions and NavigationActions can compose Routes), so it's a really good fit

Redux manages a function from some arbitrary ADT to a state transformation

ADT => State => State

Except it's old so it's not curried so it looks like this instead

const myReducer: (s: State, a: ADT) => State = ...

This function is called the reducer, and the ADT is called an Action. Your reducer, along with initial state, is given to a simple state manager called the store

const store = createStore(myReducer, initialState);

Doing this:

store.dispatch(someActionADT)

Will invoke our reducer and trigger a global state transformation. This allows us to encapsulate our app's side effects into our reducer

Our example application's ADTs

ADTUsage
MyRouteUsed by fp-ts-routing to represent a route the browser can point to
MyStateUsed by Redux to represent your app's global state
MyActionUsed by Redux to represent a state transformation
RouteActionUsed by fp-ts-routing-redux to represent a route event that can transform state with Redux
ResponseActionWill be used later by redux-observable to represent the response of a fetch that can transform state with Redux
NavigationWill be used later by fp-ts-routing-redux to represent a change to the browser's current URL

For the sake of sanity, we will implement these ADTs using morphic-ts

import { makeADT, ofType } from '@morphic-ts/batteries/lib/summoner-BASTJ'
import { Navigation } from 'fp-ts-routing-redux'

// define our app's ADTs

const _MyRoute = makeADT('type')({
  ...
  notFound: ofType<{}>(),
});
type MyRoute = typeof _MyRoute

const _RouteAction = makeADT('type')({
  ...
  route: ofType<MyRoute>(),
})
type RouteAction = typeof _RouteAction
const _ResponseAction = makeADT('type')({
  ...
  data: ofType<...>(),
})
type ResponseAction = typeof _ResponseAction
const _MyAction = makeADT('type')({
  ...
  routeAction: ofType<RouteAction>(),
  responseAction: ofType<ResponseAction>(),
  navigationAction: ofType<Navigation<MyRoute>>(),
});
type MyAction = typeof _MyAction

interface MyState {
  ...
  currentRoute: MyRoute;
}

Handling route events with Redux middleware

Redux can accept middlewares. This is a good fit for our router

import { createStore, applyMiddleware } from 'redux'
import * as R from 'fp-ts-routing';
import { routeMiddleware } from 'fp-ts-routing-redux'

// handle our app's routing

const myParser = R.zero<MyRoute>().map(...);
const myDefaultRoute = _MyRoute.of.notFound({});
const initialState = {
  ...
  currentRoute: MyRoute.notFound({}),
}

const myReducer = (state: MyState, action: MyAction) => MyAction.match({
  ...,
  routeAction: (route: MyRoute) => {...state, currentRoute: route},
  responseAction: (newData) => {...state, data: newData},
})(action);

// will invoke the store's `dispatch` on each new route event
const myRouteMidleware = routeMiddleware<MyRoute, MyAction>(
  myParser,
  myDefaultRoute,
  (r: MyRoute): MyAction => _MyAction.of.responseAction(...),
);

const store = createStore(
  myReducer,
  initialState,
  applyMiddleware(myRouteMidleware),
);

However, we often want to trigger asynchronous code as a result of a route event, which we are unable to do in our reducer

We must consider redux asynchronous middlewares

Triggering asynchronous side-effects from route events with redux-observable

redux-observable is the redux asynchronous middleware that best fits our usage**

redux-observable ties redux together with rxjs (the best streaming solution in typescript) with Epics that return Observables that are in turn subscribed to your store's dispatch with middleware

In fact, since our router is naturally an Observable, we can replace our routeMiddleware with a routeObservable

We can map our RouteActions to make asynchronous calls to fetch that push ResponseActions

We must push the original RouteAction as well so we can update our Route in our app's state

We can return routeObservable from our Epic to subscribe our RouteActions and ResponseActions to our dispatch

(RouteType is just a wrapper for a history action with a less confusing name in this context)

import * as Rx from 'rxjs'
import {
  Epic,
  createEpicMiddleware,
} from 'redux-observable'
import { routeObservable, RouteType } from 'fp-ts-routing-redux';

const myRouteObservable: Rx.Observable<ResponseAction> = routeObservable<MyRoute>(
  parser,
  notFoundRoute
).pipe(
  Rx.map(([route]: [MyRoute, RouteType]): MyAction => _RouteAction.of.route(route)),
  Rx.map(
    (action: RouteAction) => Rx.merge(
      routeAction,
      routeAction.pipe(
        Rx.map((action: RouteAction): Observable<Response> => fromFetch(
            'https://jsonplaceholder.typicode.com/posts/1',
        )),
        Rx.mergeAll(),
        Rx.map((resp: Response): ResponseAction => _ResponseAction.of....)
      ),
    ),
  ),
  Rx.mergeAll(),
);

const myRouteEpic: Epic<
  MyAction, ResponseAction, MyState
> = (): Rx.Observable<ResponseAction> => myRouteObservable;

const myEpicMiddleware = createEpicMiddleware();

const store = createStore(
  myReducer,
  applyMiddleware(myEpicMiddleware)
);

epicMiddleware.run(myRouteEpic);

If we want to have other asynchonous side effects, Epics represent your redux state and action as Observables called $state and $action. We can merge routeObservable with whatever Observables you need

const myRouteEpic: Epic<MyAction, MyAction, MyState> = (
  action$: Rx.Observable<MyAction>,
): Rx.Observable<MyAction> => Rx.merge(
  myRouteObservable,
  action$.pipe(
    // your other asynchronous code
    Rx.filter(...),
    ...
  ),
);

This is still impure. We are using side effects without safely demarcating them as IO. How would we mock this for testing?

Triggering asynchronous side-effects from route events with @matechs/epics

@matechs/effect is part of the fp-ts ecosystem that borrows concepts from scala ZIO that allow us to invoke syncronous and asynchronous side effects with Effects purely by separating them from their environments using Providers

A Stream is an Effectful Observable

Our routeStream is a Stream that accepts a NavigationProvider

@matechs-epics allows us to represent our redux-observable Epic as a Stream

So our routeStream is a Stream that, alongside our NavigationProvider, goes inside an Epic that goes inside redux-observable middleware that goes inside redux

import { stream as S, effect as T } from '@matechs/effect';
import * as Ep from '@matechs/epics';

const fetchUser = Ep.epic<MyState, MyAction>()((_, action$) =>
// TODO - implement this
// https://arnaldimichael.gitbook.io/matechs-effect/core/the-effect-system
// https://arnaldimichael.gitbook.io/matechs-effect/core/play-with-streams
// https://github.com/Matechs-Garage/matechs-effect/blob/master/packages/epics/tests/Epics.test.ts

We are able to easily mock our NavigationProvider for testing

import * as assert from "assert";
// TODO - implement this
assert.deepStrictEqual(TBD)

Rerouting with redux

Since rerouting is simply a dispatched side effect, we represent it as its own redux middleware

We can use an NavigationProvider to separate the middleware from its side effect. The default NavigationProvider is HistoryNavigationProvider, but lets roll our own for testing purposes

import * as O from 'fp-ts/lib/Option'
import * as assert from "assert";
import { compose } from 'redux'
import { createStore, applyMiddleware, compose } from 'redux'

const formatter: (r: MyRoute) => string = MyRoute.match({ ... });

const testNavigationProvider = TBD;

const myNavigationMiddlware = navigationMiddleware<MyRoute, MyAction>(
  formatter,
  (routeAction: MyAction): O.Option<Navigation<MyRoute>> => MyAction.is.RouteAction(routeAction)
    ? O.some(Navigation.push(routeAction.route))
    : O.none,
  testNavigationProvider,
);

const store = createStore(
  myReducer,
  compose(
    applyMiddleware(myEpicMiddleware),
    applyMiddleware(myNavigationMiddleware),
  ),
);

epicMiddleware.run(myRouteEpic);

// run some tests
assert.deepStrictEqual(TBD)

Note: we must use separate RouteActions and navgiationActions so that our RouteActions don't dispatch navigationActions. RouteActionss are for storing the current route in global state, navigationActions are for modifying the brower's url. navigationActions should dispatch RouteActions, but not the other way around.

* Redux uses function composition, while Flux uses callback registration. Redux state is immutable, while Mobx state is mutable.

* redux-thunk accepts any function, while redux-observable enforces purity by requiring your impure asynchronous function to be demarcated as an Observer. redux-saga has a similar approach using generator functions(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function), but Observables are monadic. redux-loop, rather than being a middleware, allows the reducer itself to behave asynchronously, but Cmd has no way to compose with outside event streams, which is what our router must do.