Skip to content

Instantly share code, notes, and snippets.

@sktt
Last active September 30, 2017 18:40
Show Gist options
  • Save sktt/875a59dab38eef8e4102eb32fb7e05aa to your computer and use it in GitHub Desktop.
Save sktt/875a59dab38eef8e4102eb32fb7e05aa to your computer and use it in GitHub Desktop.
Simple state mgmt using rxjs for redux fans
date title descr
Thu 13 Jul 2017 10:41:19 AM CEST
Roll your own redux
Simple state mgmt using rxjs for redux fans

Roll your own redux

using rxjs

The core redux library is great, I am not going to criticize it, but when you're building a somewhat sophisticated application you're going to start pulling in extensions such as redux-thunk or redux-saga. If you're using react you probably also pull in react-redux. Soon you'll start googling redux-someusecase for everything you want to do, adding weird and stupid code that you wouldn't need.

In this article I want to show how easy it is to build a redux-like application using rxjs. There are only a few concepts that Redux features, which can simply be implemented with a few lines of rxjs. I attached a few files which I find useful for new projects in the end!

Actions

The idea of Actions and ActionCreators was introduced in Flux and adopted by redux. It's a great idea because they're just plain objects that gives a good overview of how the application works. An ActionCreator is just a function that returns an Action to be handled by application. I add two example action here, that I will use for demostration.

// actions.js
export const INIT_APP = '__init__'
export const ADD_TODO = 'ADD_TODO'

export const initApp = initialState => ({
  type: INIT_APP,
  payload: initialState
})

export const addTodo = todo => ({
  type: ADD_TODO,
  payload: t
})

Data flow

Actions will be handled by rx! To have the actions handled, they will be pushed into an observable. In order to have a way to both push and observe actions, a Subject becomes handy. It will act as a pubsub, ie. data can pushed and observed to at the same time, which naturally should be done in a controlled mannar! Subjects should be used with caution, but for this particular use case they are perfect. Generally, this will be the single place in an application where I'd use a Subject anyway.

// dispatcher.js
import {ReplaySubject} = from 'rxjs'

// A ReplaySubject will replay the n last values it has seen to each new subscriber
const dispatcher = new ReplaySubject()

// this is the dispatch function where all actions will go through
export const dispatch = (act) => dispatcher.next(act)

// Observable of actions
export const action$ = dispatcher.asObservable()

Now we can push actions using dispatch(someAction), and observe them action$. ie:

action$.subscribe(action => {
  console.log('next action', action.type, action.payload)
})

// now i can do this!
dispatch(initApp({todos: []}))
dispatch(addTodo('Code less'))

// -> next action __init__ {todos: []}
// -> next action ADD_TODO Code less

Reducers

Actions just say what should be done, but how the application state will update given a particular action is handled by reducers. In redux you'd have a reducer as a (preferably) pure function with a signature like (currentState, action) => newState. Then you'd have a switch clause to do different updates depending on what action.type is.

// state.js first try
export const state$ = actions$
  .map(({type, payload}) => currentState => {
    let newState = null

    switch(type) {
      case INIT_APP:
        // replace `currentState` with `action.payload`
        newState = payload
      case ADD_TODO:
        // replace `currentState` with an updated state (one with the added todo)
        newState = {
          ...currentState,
          todos: [payload, ...currentState.todos]
        }
      default:
        newState = currentState
    }

    return newState
  })
  .scan((state, update) => update(state))

We can already see that the switch statement can easily be replaced with the filter operator and a convenience function, handleAction. That is great, because I never remember the strange syntax of switch/case statements

// state.js second try
const handleAction = t => action$.filter(({type}) => type === t)

export const state$ = Observable.merge(
  // replace `currentState` with `action.payload`
  handleAction(INIT_APP).map(action => currentState => action.payload),

  // replace `currentState` with an updated state (one with the added todo)
  handleAction(ADD_TODO).map(action => currentState => ({
    ...currentState,
    todos: [action.payload, ...currentState.todos]
  }))
)
.scan((state, update) => update(state), null)

To put the above in words, when a new action comes in, a function, that takes the currentState and modifies it using the action in its higher scope, is created and returned. For each update coming in, it gets applied to current state.

// index.js
import {dispatch} from './dispatcher'
import {initApp} from './actions'
import {App} from './App'
import {state$} from './state'

state$.subscribe(state => {
  ReactDOM.render(<App {...state} />, document.findElementById('root'))
})

// init the app
dispatch(initApp({
  weed: 'weed'
}))

Async Actions

Normally in redux when you want to do anything async you have to either pull in redux-saga or redux-thunk. Of course you want async cus, everything is async. I dislike both saga and thunk though.

  • Pretentious naming, saga features es7 generators which are cool not much more. Thunk, wtf is thunk? Some silly haskell jargon. I know for sure Elm would never name anything like that.
  • Ok the first bullet doesn't have much substance to it but this one: These plugins introduces the incentive of not anymore just dispatching actions from react components, but also from "thunks" or "sagas".
  • Thunk features overloading dispatch with not just handling action objects but now also functions (thunks?) which in turn can call dispatch again. Let alone now also dispatch would also return a Promise to allow for sequential dispatches. It's a disaster!

With rxjs these things are already available thanks to the nature of Observables. Let's instead see what an async action would look like with rxjs:

Somewhere in react land we've dispatched an action...

// my_button.js
import {dispatch} from './dispatcher'
import {asyncThing} from './actions'
const MyButton => (
  <button onClick={() => dispatch(asyncThing())}>Wow free weed</button>
)

And it will be handled by rxjs...

// state.js
// (....)
// 
const asyncResponse$ = handleAction(REQ_ASYNC_THING)
  .switchMap(({payload}) => Observable.from(fetch('https://www.google.com'))
    .map(res => 'ok!')
    .catch(err => Observable.of('whoops'))
  )

export const state$ = Observable.merge(
  // (....)
  handleAction(REQ_ASYNC_THING).map(({payload}) => state => ({
    asyncThingLoading: true,
    ...state
  })),
  asyncResponse$.map(status => state => ({
    asyncThingLoading: false,
    asyncResult: status,
    ...state
  }))
)

There's no longer any need of calling three different action creators for a single task. But redux-think folks might be tempted to do this instead...

import {Observable} from 'rxjs'

handleAction(REQ_ASYNC_THING)
  .switchMap(({payload}) => Observable.from(fetch('https://www.google.com'))
    .map(res => ({res: 'ok!'}))
    .catch(err => Observable.of({err: 'whoops'}))
  )
  .subscribe(({res, err}) => {
    if (err) {
      dispatch(asyncThingFail(err))
    } else {
      dispatch(asyncThingSuccess(res))
    }
  })

export const state$ = Observable.merge(
  // (....)
  handleAction(REQ_ASYNC_THING).map ....,
  handleAction(SUC_ASYNC_THING)...,
  handleAction(ERR_ASYNC_THING) ....
)

I advice against that, because this makes the code harder to control because an the fail or success actions now can be dispatched from anywhere.

react-redux sugar Proivder and connect.

I don't like neither of them. Here's why:

  • Provider is a react component that doesn't render anything. React is a View library and it's components are supposed to render something.
  • Provider uses the uses react context which is disencouraged
  • connect encourages state flowing in from multiple sources rather than from the root component. This means that application state can flow in both from a parent component but also direcly from the connected state
  • connect-ing your component ties it closer to your app state which makes your components less reusable.
  • connect introduces are{States,OwnProps,StateProps,MergedProps}Equal. Great! 4 new functions in case shouldComponentUpdate isn't enough!

I happens every now and then that a parent component of a connected one turned out to need the same state, resulting in multiple components in the hiarchy connecting to the same state, meanwhile that state can be passed down in the render() as props. It makes it less obvious in what order updates are applied to components. But most people seem to like it (it's convenient) so at least here's how one can create a connect function.

// connect.js
// not going to mess with contextTypes for this one. instead just require it
import React, {Component} from 'react'
import {state$} from './state'

import {dispatch} from './dispatcher'

// even the name `mapStateToProps` is confusing because in react there is
// already component state. Here they're referring to redux state.
// Also omitting `options` as a 4th param with state update filters because I
// don't understand why they should be useful.
export default (mapStateToProps, _mapDispatchToProps = {}, _mergeProps) => {
  const mergeProps = _mergeProps || ((a, b, c) => ({...a, ...b, ...c}))

  const mapDispatchToProps = (dispatch, ownProps) => {
    if (_mapDispatchToProps.constructor === Function) {
      return _mapDispatchToProps(dispatch, ownProps)
    }

    const resu = Object.keys(_mapDispatchToProps).reduce((acc, key) => {
      acc[key] = (...args) => dispatch(
        _mapDispatchToProps[key].apply(null, args)
      )
      return acc
    }, {})

    return resu
  }

  return WrappedComponent => class ConnectedComponent extends Component {
    componentWillMount () {
      this.sub = state$.subscribe(this.setState.bind(this))
    }

    componentWillUnmount () {
      this.sub.unsubscribe()
    }

    render () {
      if (!this.state) return null

      return <WrappedComponent {...mergeProps(
        mapStateToProps(this.state, this.props),
        mapDispatchToProps(dispatch, this.props),
        this.props
      )} />
    }
  }
}

Like I said though, the subscription to the application state should not be done like this but separated from anything that has anything to do with react components, but that's my own opinion and anyone is welcome to have their own.....

Unexpected control 8)

Ah, I mentioned recording actions.. Since I defined the dispatcher above as a ReplaySubject, I can at anytime subscribe to it will replay to me all dispatched actions! Kindof cool! For instance I we could create an error reporter:

// index.js

const actionLog = []
action$.subscribe(a => actionLog.push(a))

state$.subscribe({
  next(state) {
    ReactDOM.render(<App {...state} />, document.findElementById('root'))
  },
  error(err) {
    ReactDOM.render(<ErrorPage {...err} />, document.findElementById('root'))

    // send error report somwhere..
    createErrorReport(actionLog)
  }
})

That's it! First post on code cooking! Hope you like it.

// connect.js
// not going to mess with contextTypes for this one. instead just require it
import React, {Component} from 'react'
import {state$} from './state'
import {dispatch} from './dispatcher'
// even the name `mapStateToProps` is confusing because in react there is
// already component state. Here they're referring to redux state.
// Also omitting `options` as a 4th param with state update filters because I
// don't understand why they should be useful.
export default (mapStateToProps, _mapDispatchToProps = {}, _mergeProps) => {
const mergeProps = _mergeProps || ((a, b, c) => ({...a, ...b, ...c}))
const mapDispatchToProps = (dispatch, ownProps) => {
if (_mapDispatchToProps.constructor === Function) {
return _mapDispatchToProps(dispatch, ownProps)
}
const resu = Object.keys(_mapDispatchToProps).reduce((acc, key) => {
acc[key] = (...args) => dispatch(
_mapDispatchToProps[key].apply(null, args)
)
return acc
}, {})
return resu
}
return WrappedComponent => class ConnectedComponent extends Component {
componentWillMount () {
this.sub = state$.subscribe(this.setState.bind(this))
}
componentWillUnmount () {
this.sub.unsubscribe()
}
render () {
if (!this.state) return null
return <WrappedComponent {...mergeProps(
mapStateToProps(this.state, this.props),
mapDispatchToProps(dispatch, this.props),
this.props
)} />
}
}
}
import {ReplaySubject} from 'rxjs'
const subj = new ReplaySubject(20) // remember last 20 events
export const dispatch = act => subj.next(act)
export const handle = type => subj
.asObservable()
.filter((act) => act.type === type)
.map(({payload}) => payload)
export const handlePattern = typePattern => subj
.asObservable()
.filter((act) => typePattern.test(act.type))
import 'typeface-roboto'
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import './index.css'
import injectTapEventPlugin from 'react-tap-event-plugin'
import {dispatch} from './dispatcher'
import {state$, actions} from './state'
injectTapEventPlugin()
state$.subscribe({
next (state) {
ReactDOM.render(<App {...state} dispatch={dispatch} />, document.getElementById('root'))
}
})
dispatch(actions.initApp({}))
import {Observable} from 'rxjs'
import {handle} from './dispatcher'
const INIT_APP = '__init__'
// an additional action...
// const SOME_ACTION = 'SOME_ACTION'
export const actions = {
initApp: initialState => ({
type: INIT_APP,
payload: initialState
})
// someAction: p => ({
// type: SOME_ACTION,
// payload: p
// })
}
const stateTransform$ = Observable.merge(
handle(INIT_APP).map(curstate => initial => initial)
// handle other action..
//, handle(SOME_ACTION).map(curstate => act => ({
// ...curstate
// }))
// etc..
)
export const state$ = stateTransform$
.scan((state, transform) => transform(state))
.shareReplay(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment