@wfh/redux-toolkit-observable

A convenient Redux-toolkit + Redux-observable encapsulation

Usage no npm install needed!

<script type="module">
  import wfhReduxToolkitObservable from 'https://cdn.skypack.dev/@wfh/redux-toolkit-observable';
</script>

README

Redux-toolkit And Redux-abservable

Reference to https://redux-toolkit.js.org/ https://redux-observable.js.org/

Understand React component, Redux and Epic

flowchart TD

Epic([Epic])

subgraph Redux
  rtk[Redux-toolkit<br>middleware]
  ro[Redux-observable<br>middleware]
  reduxStore[(Redux store)]
end

comp([component]) --> |dispatch<br>reducer action| rtk
comp -.-> |useEffect,<br>props| hook["@wfh hook,<br>react-redux"]
hook -.-> |subscribe,<br>diff| reduxStore
rtk --> |update| reduxStore
rtk --> ro
ro --> |action stream,<br>state change stream| Epic
Epic --> |dispatch<br>async<br>reducer action| rtk
Epic --> |request| api[(API)]
ro -.-> |diff| reduxStore

Author slice store

0. import dependencies and polyfill

Make sure you have polyfill for ES5: core-js/es/object/index, if your framework is not using babel loader, like Angular.

import { PayloadAction } from '@reduxjs/toolkit';
// For browser side Webpack based project, which has a babel or ts-loader configured.
import { getModuleInjector, ofPayloadAction, stateFactory } from '@wfh/redux-toolkit-abservable/es/state-factory-browser';

For Node.js server side project, you can wrapper a state factory somewhere or directly use "@wfh/redux-toolkit-abservabledist/redux-toolkit-observable'"

1. create a Slice

Define your state type

export interface ExampleState {
  ...
}

create initial state

const initialState: ExampleState = {
  foo: true,
  _computed: {
    bar: ''
  }
};

create slice

export const exampleSlice = stateFactory.newSlice({
  name: 'example',
  initialState,
  reducers: {
    exampleAction(draft, {payload}: PayloadAction<boolean>) {
      // modify state draft
      draft.foo = payload;
    },
    ...
  }
});

"example" is the slice name of state true

exampleAction is one of the actions, make sure you tell the TS type PayloadAction<boolean> of action parameter.

Now bind actions with dispatcher.

export const exampleActionDispatcher = stateFactory.bindActionCreators(exampleSlice);

2. create an Epic

Create a redux-abservable epic to handle specific actions, do async logic and dispatching new actions .

const releaseEpic = stateFactory.addEpic((action$) => {
  return merge(
    // observe incoming action stream, dispatch new actions (or return action stream)
    action$.pipe(ofPayloadAction(exampleSlice.actions.exampleAction),
      switchMap(({payload}) => {
        return from(Promise.resolve('mock async HTTP request call'));
      })
    ),
    // observe state changing event stream and dispatch new Action with convient anonymous "_change" action (reducer callback)
    getStore().pipe(
      map(s => s.foo),
      distinctUntilChanged(),
      map(changedFoo => {
        exampleActionDispatcher._change(draft => {
          draft._computed.bar = 'changed ' + changedFoo;
        });
      })
    ),
    // ... more observe operator pipeline definitions
  ).pipe(
    catchError(ex => {
      // tslint:disable-next-line: no-console
      console.error(ex);
      // gService.toastAction('网络错误\n' + ex.message);
      return of<PayloadAction>();
    }),
    ignoreElements()
  );
}

action$.pipe(ofPayloadAction(exampleSlice.actions.exampleAction) meaning filter actions for only interested action , exampleAction, accept multiple arguments.

getStore().pipe(map(s => s.foo), distinctUntilChanged()) meaning observe and reacting on specific state change event. getStore() is defined later.

exampleActionDispatcher._change() dispatch any new actions.

3. export useful members

export const exampleActionDispatcher = stateFactory.bindActionCreators(exampleSlice);
export function getState() {
  return stateFactory.sliceState(exampleSlice);
}
export function getStore() {
  return stateFactory.sliceStore(exampleSlice);
}

4. Support Hot module replacement (HMR)

if (module.hot) {
  module.hot.dispose(data => {
    stateFactory.removeSlice(exampleSlice);
    releaseEpic();
  });
}

5. Connect to React Component

TBD.

Use slice store in your component

1. Use reselect

2. About Normalized state and state structure

Why we wrapper redux-toolkit + redux-observable

What's different from using redux-toolkit and redux-abservable directly, what's new in our encapsulation?

  • newSlice() vs Redux's createSlice() newSlice() implicitly creates default actions for each slice:

    • _init() action
      Called automatically when each slice is created, since slice can be lazily loaded in web application, you may wonder when a specific slice is initialized, just look up its _init() action log.

    • _change(reducer) action
      Epic is where we subscribe action stream and output new action stream for async function.

      Originally to change a state, we must defined a reducer on slice, and output or dispatch that reducer action inside epic.

      In case you are tired of writing to many reducers on slice which contains very small change logic, _change is a shared reducer action for you to call inside epic or component, so that you can directly write reducer logic as an action payload within epic definition.

      But this shared action might be against best practice of redux, since shared action has no meaningful name to be tracked & logged. Just save us from defining to many small reducers/actions on redux slice.

  • Global Error state
    With a Redux middleware to handle dispatch action error (any error thrown from reducer), automatically update error state.

    export declare class StateFactory {
      getErrorState(): ErrorState;
      getErrorStore(): Observable<ErrorState>;
      ...
    }
    
  • bindActionCreators()
    our store can be lazily configured, dispatch is not available at beginning, thats why we need a customized bindActionCreators()

The most frequently used RxJS operators

There are 2 scenarios you need to interect directly with RxJS.

  • In Redux-observable Epic, to observe incoming action stream, and dispatching new actions (or return outgoing action stream)

  • In Redux-observable Epic, observe store changing events and react by dispatching new actions.

  1. First you have imports like beblow.
import * as rx from 'rxjs';
import * as op from 'rxjs/operators';
  1. Return a merge stream from Epic function

Typescript compile with compiler option "declaration: true" issue

"This is likely not portable, a type annotation is necessary" https://github.com/microsoft/TypeScript/issues/30858

It usally happens when you are using a "monorepo", with a resolved symlink pointing to some directory which is not under "node_modules", the solution is, try not to resolve symlinks in compiler options, and don't use real file path in "file", "include" property in tsconfig.

Tiny redux toolkit

It does not depends on Redux, no Redux is required to be packed with it.

This file provide some hooks which leverages RxJS to mimic Redux-toolkit + Redux-observable which is supposed to be used isolated within any React component in case your component has complicated and async state changing logic.

Redux + RxJs provides a better way to deal with complicated UI state related job.

  • it is small and supposed to be well performed
  • it does not use ImmerJS, you should take care of immutability of state by yourself
  • because there is no ImmerJS, you can put any type of Object in state including those are not supported by ImmerJS