ctrlplusb / easy-peasy

Vegetarian friendly state for React
https://easy-peasy.dev
MIT License
5.03k stars 190 forks source link

Easy Peasy + React Router + TypeScript integration #304

Closed therealparmesh closed 5 years ago

therealparmesh commented 5 years ago

This is somewhat related to the discussion in https://github.com/ctrlplusb/easy-peasy/issues/25.

I have a project where I'd like to keep the state of the React Router in sync with Easy Peasy's Redux store, preferably without compromising on TypeScript. I don't intend to use the routing state as a source of truth for the components. I'd like to have it for basic time travel debugging via Redux dev tools for use as a part of logger logic in the store.

I tried to use connected-react-router and redux-first-history and found them to not be clean fits. I had problems with types and with sync timing.

Ultimately I ended up implementing my own solution. I'm curious as to what other people’s experiences have been like, and if we should consider having these sort of things available as add-ons in the future.

Thank you for this awesome library and all the amazing work y'all or doing on it. I was extremely skeptical of abstractions on Redux until I came across this project. 😄

My approach, condensed:

import { action, createStore, Action } from 'easy-peasy';
import { createBrowserHistory, Location, Action as HistoryAction } from 'history';
import { Router } from 'react-router-dom';

const history = createBrowserHistory();

interface RouterModel {
  location: Location;
  action: HistoryAction;
  route: Action<
    RouterModel,
    {
      location: Location;
      action: HistoryAction;
    }
  >;
}

interface StoreModel {
  router: RouterModel;
}

const history = createBrowserHistory();

const router: RouterModel = {
  location: history.location,
  action: history.action,
  route: action((state, payload) => {
    state.location = payload.location;
    state.action = payload.action;
  }),
};

const model: StoreModel = {
  router,
};

const store = createStore(model);

let isReduxTraveling = false;

store.subscribe(() => {
  if (store.getState().router.location.key !== history.location.key) {
    isReduxTraveling = true;

    history.push(store.getState().router.location);

    isReduxTraveling = false;
  }
});

history.listen((location, action) => {
  if (!isReduxTraveling) {
    store.getActions().router.route({
      location,
      action,
    });
  }

  isReduxTraveling = false;
});

<Router history={history} />;
aalpgiray commented 5 years ago

How about this one

import { createBrowserHistory } from "history";
import { createReduxHistoryContext } from "redux-first-history";

const {
  createReduxHistory,
  routerMiddleware,
  routerReducer
} = createReduxHistoryContext({
  history: createBrowserHistory(),
  reduxTravelling: true
});

export { createReduxHistory, routerMiddleware, routerReducer };
export interface StoreModel {
  router: Reducer<any>;
}

const storeModel: StoreModel = {
  router: reducer(routerReducer),
};
therealparmesh commented 5 years ago

I tried this library as well. It has issues with types, especially when you wrap the store. Also, it doesn’t utilize location key for location comparison or for pushing to history. And I don’t need the actions it provides for pushing to history through Redux rather than using React Router.

lostfields commented 5 years ago

@therealparmesh I like it a lot, and I modified it a bit to my usage where I used redux-first-history but had a few traveling issues. No idea why it doesn't work but your worked nice!

I adapted the code and modified it a bit to support the interface of redux-first-history and typescript (it's easy to swap between these) and the ability to turn off traveling (to save cpu cycles)

history.ts

import { Store, Action, action, actionOn, ActionOn } from 'easy-peasy'
import { Middleware } from 'redux'
import { History, Location, Action as HistoryAction } from 'history'

let isReduxTraveling = false

export type RouterState = {
    location: Location
    action: HistoryAction

    route: Action<RouterState, RouterState>
    push: Action<RouterState, { to: string }>
    replace: Action<RouterState, { to: string }>
}

type ReduxHistoryContext = {
    routerModel: RouterState
    routerMiddleware: Middleware
    createReduxHistory: (store: Store) => History
}

// https://github.com/ctrlplusb/easy-peasy/issues/304
export function createReduxHistoryContext(options: { history: History, reduxTravelling?: boolean, routerReducerKey?: string }): ReduxHistoryContext {
    let { history, routerReducerKey, reduxTravelling } = options

    if(!routerReducerKey)
        routerReducerKey = 'router'

    return ({
        routerModel: {
            location: history.location,
            action: history.action,
            route: action((state, payload) => {           
                return {
                    ...state,
                    location: payload.location,
                    action: payload.action
                }
            }),
            push: action((state, payload) => ({ ...state })),
            replace: action((state, payload) => ({ ...state }))
        },
        routerMiddleware: (store) => {
            return (next) => {
                return (action) => {
                    switch(action.type) {
                        case `@action.${routerReducerKey}.push`:
                            history.push(action.payload.to)
                            break

                        case `@action.${routerReducerKey}.replace`:
                            history.replace(action.payload.to)
                            break

                        case `@@router/CALL_HISTORY_METHOD`:
                            let method = action.payload && action.payload.method || ''
                            switch(method) {
                                case 'push':
                                case 'replace':
                                case 'go':
                                case 'goBack':
                                case 'goForward':
                                    history[method].call(history, ...action.payload.args)
                            }
                            break
                    }

                    return next(action)
                }
            }
        },
        createReduxHistory: (store: Store) => {                
            history.listen((location, action) => {
                if (!isReduxTraveling) {
                    let actions = store.getActions()

                    if( routerReducerKey! in actions ) {
                        actions[routerReducerKey!].route({
                            location,
                            action,
                        })
                    }
                }

                isReduxTraveling = false;
            })

            if(reduxTravelling === true) {
                store.subscribe(() => {
                    let state = store.getState()

                    if (routerReducerKey! in state) {
                        let router: RouterState = state[routerReducerKey!]

                        if(router.location.key !== history.location.key) {
                            isReduxTraveling = true

                            history.push(router.location)

                            isReduxTraveling = false
                        }
                    }
                })
            }

            return history
        }
    })
}

then create your easypeasy store etc;

store.ts

import { createStore } from 'easy-peasy'

import { createReduxHistoryContext, RouterState } from './history'
import { createBrowserHistory } from 'history'

import model from './model'

const { createReduxHistory, routerMiddleware, routerModel } = createReduxHistoryContext({ 
    history: createBrowserHistory(),
    reduxTravelling: true, // turn it off in production
    routerReducerKey: 'router'
})

export const store = createStore({ 
    ...model,
    router: routerModel // reducer(routerReducer) if you are using redux-first-history
}, { disableImmer: true, devTools: true, middleware: [routerMiddleware] })

export const history = createReduxHistory(store as any)

const { useStoreActions, useStoreState, useStoreDispatch } = createTypedHooks<typeof model & { router: RouterState }>()

export {
  useStoreActions,
  useStoreState,
  useStoreDispatch
}

Middleware will listen to redux-first-history dispatch actions as well

salvoravida commented 5 years ago

@therealparmesh can you explain more what issue have with redux-first-history and TS?

i would like to rewrite redux-first-history with TS.

Salvo.