date | title | descr |
---|---|---|
Thu 13 Jul 2017 10:41:19 AM CEST |
Roll your own redux |
Simple state mgmt using rxjs for redux fans |
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!
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
})
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
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'
}))
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 calldispatch
again. Let alone now alsodispatch
would also return aPromise
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.
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 disencouragedconnect
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 stateconnect
-ing your component ties it closer to your app state which makes your components less reusable.connect
introducesare{States,OwnProps,StateProps,MergedProps}Equal
. Great! 4 new functions in caseshouldComponentUpdate
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.....
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.