- Notifications
You must be signed in to change notification settings - Fork4
Like Redux, but smaller
CaptainCodeman/rdx
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Like Redux, but smaller ...
This is a simple immutable state store along the lines of Redux but significantly smaller - it helps to build apps with super-tiny JavaScript payloads. It provides all the basic features for creating a client-side app including:
- Redux-like state store (actions / reducers / middleware)
- Root reducer utility function (combineReducers)
- Handling of async actions (aka 'thunks')
- Mixin to connect custom elements to the store (map state to properties and events to store dispatch)
Total size: 1.47 Kb minified / 640 bytes gzipped
With additional enhancements:
- Redux DevTools integration for debug and time-travel
- State hydration & persistence with action filtering, debounce and pluggable storage + serialization (defaults to localStorage and JSON)
Total size: 2.19 Kb minified / 969 bytes gzipped
See a fully functionalexample app built using this.
While the aim isn't to be 100% compatible with Redux, it can work with the Redux DevTools Extension and there is anexperimentalcompat module to simulate the Redux API and adapt existing Redux middleware.
To create your state store:
import{Store,combineReducers,connect,thunk,persist,devtools}from'@captaincodeman/rdx'// a very simple reducer stateconstcounter=(state=0,action)=>{switch(action.type){case'counter/inc':returnstate+1case'counter/dec':returnstate-1default:returnstate}}// more complex state for data loadingconsttodos=(state={data:{},loading:false,err:'',},action)=>{switch(action.type){case'todos/request':return{...state,loading:true}case'todos/receive':return{...state,loading:false,data:action.payload}case'todos/failure':return{...state,loading:false,err:action.payload}default:returnstate}}// create root reducerconstreducer=combineReducers({ counter, todos})// initial state could come from SSR stringconstinitial=undefined// create the store - this will persist state to localStorage,// support async actions (thunks) and allow time-travel debug// using the Redux devtools extensionconststore=devtools(persist(thunk(newStore(initial,reducer))))
I've tried to re-think a few things to keep the size down because a lot of what Redux does is very clever but not necessary for what I need. The flexible "currying everywhere" approach to configuration may be very extensible but is more complex than required and confusing to use. The checks, warnings and error messages are not required when using TypeScript and in fact a simpler configuration reduces the requirement for it anyway.
Wherever possible we can also take advantage of existing web platform features instead of including additional JS code that simply replicates them. The ability to subscribe to something to receive events for instance is very common in the browser so for notification of state changes and dispatched actions we rely on the inbuiltEventTarget.
Unfortunately, theEventTargetconstructor() is not currently supported on WebKit so if you want to support Safari users, an additional small (589 byte) polyfill is needed which can be loaded only when required:
<script>try{newEventTarget}catch(e){document.write('<script src="https://unpkg.com/@ungap/event-target@0.1.0/min.js"><\x2fscript>')}</script>
Oncesupport is added this polyfill can be removed and will stop loading automatically.
But it's not "just" a Redux implementation, it makes it much easier to develop your app.
I really liked the approach ofredux-rematch to reduce the boilerplate required when using Redux. For more background and the motivation behind this approachseeredesigning-redux.
This brings that same approach to Rdx and allows you to define your state models in a very small and compact way, without verbose boilerplate code by providing helpers to create the store for you and plugins to add common functionality such as routing.
See alive example or checkout thesource code. The usage example below is based on this example.
This helps create a store instance for you and wires up dispatch and async effects for yor models. It starts like this, we'll see where themodels come from later:
import{createStore}from'@captaincodeman/rdx'import*asmodelsfrom'./models'exportconststore=createStore({ models})
The store that is created is a regular Rdx store with some additional, auto-generated actionCreator-type methods added to thedispatch method to make using the store easier ... we'll get to those later.
If we require additional store functionality, that can be added by wrapping the store or providing plugins. Lets add state persistence and hydration usinglocalStorage and also wire up the Redux DevTools extension (both provided by Rdx) plus add routing using a plugin provided by this package.
First, we'll define our store configuration, including routes, in a separate file using atiny client-side router package:
importcreateMatcherfrom'@captaincodeman/router'import{routingPlugin}from'@captaincodeman/rdx'import*asmodelsfrom'./models'constroutes={'/':'home-view','/todos':'todos-view','/todos/:id':'todo-view','/*':'not-found',}constmatcher=createMatcher(routes)constrouting=routingPlugin(matcher)exportconstconfig={ models,plugins:{ routing}}
We'll import the exportedconfig and use thecreateStore helper to create an instance of the Rdx store and this time we'll decorate it with thedevtools andpersist enhancers that therdx package provides so we get the integration with Redux DevTools plus state persistence usinglocalStorage. It's only slightly more complex than the first example:
import{createStore,StoreState,StoreDispatch,EffectStore}from'@captaincodeman/rdx'import{devtools,persist}from'@captaincodeman/rdx'import{config}from'./config'exportconststore=devtools(persist(createStore(config)))exportinterfaceStateextendsStoreState<typeofconfig>{}exportinterfaceDispatchextendsStoreDispatch<typeofconfig>{}exportinterfaceStoreextendsEffectStore<Dispatch,State>{}
Note theState,Dispatch andStore interfaces provide strongly-typed access to the store.
So what about the models that are imported? That's really where all the 'action' is or actionsare, it's a Redux pun see ... oh, nevermind, anyway let's focus on those. All the models are in a separate/models module which simply re-exports and names the individual state branches. This makes it easy to manage as the state in your app grows.
export{counter}from'./counter'export{todos}from'./todos'
The state branches are then defined in their own files. A simple counter state is, well, simple ... because why should itneed to be complicated?
import{createModel}from'@captaincodeman/rdx'exportconstcounter=createModel({state:{value:0,},reducers:{inc(state){return{ ...state,value:state.value+1};},add(state,payload:number){return{ ...state,value:state.value+payload};},},})
The state can be as simple or complex as needed. For this example we could have made the state be the numeric value directly, but that isn't typical in a real app. Likewise, the payload passed to a reducer method can be more than just a single value, it would be the same type of payload that an action typically has. Hmmn ... can you see where this is going?
ThecreateModel helper is really there just to aid typing. It not only defines the initial state but also infers the state type, so it doesn't need to be defined in each reducer function. Each reducer must accept the state as the first parameter and then an optional payload as a second parameter. Why this 'restriction'? Because these reducer methods are transformed into actionCreator-type functions that bothcreate anddispatch an action in a single call.
Take theadd reducer method on thecounter state model above. This is converted into a strongly typed method on the store dispatch which allows you to call strongly typed methods to dispatch actions such as:
dispatch.counter.add(5)
To be clear - we're still using a state store and are dispatching actions that go through any middleware and eventually may hit the reducer, we are not just calling the reducer directly as it may appear. We still have immutable and predictable state, just without all the boilerplate code.
The action type is created automatically based on the name of the model and the name of the reducer function, so the example above would cause an action to be dispatched with the typecounter/add (which is the naming convention Redux now recommends).
If we were using Redux we might have code that looks more like this:
exportenumCounterTypes{COUNTER_INC:'COUNTER_INC' COUNTER_ADD:'COUNTER_ADD'}exportinterfaceCounterInc{readonlytype:COUNTER_INC}exportinterfaceCounterAdd{readonlytype:COUNTER_ADDreadonlypayload:number}exporttypeCounterActions=CounterInc|CounterAddexportconstcreateCounterInc=()=>{return<CounterInc>{type:COUNTER_INC,}}exportconstcreateCounterAdd=(value:number)=>{return<CounterAdd>{type:COUNTER_ADD,payload:value,}}exportinterfaceCounterState{value:number}constinitialState:CounterState={value:0,};exportconstcounterReducer=(state:CounterState=initialState,action:CounterActions)=>{switch(action.type){caseCounterTypes.COUNTER_INC:return{ ...state,value:state.value+1};caseCounterTypes.COUNTER_ADD:return{ ...state,value:state.value+action.payload};default:returnstate}}// to call:store.dispatch(createCounterAdd(2))
How many times should we have to type 'counter',really? So many potential gotchas to make a mistake. That's just one simple state branch - imagine what happens when we have a large application and multiple actions in multiple state branches? This is where people might say Redux isn't worth it and is overkill - but what Reduxdoes is definitely worthwhile, it's just that it does it in a complex way.
Yes, some of this is deliberately verbose to make the point and there are various helpers that can be used to reduce some of the pain points (at the cost of extra code), but Redux definitely has some overhead - it's not simple to use and the extra code doesn't really add any value and it becomes complex to work with as it's often spread across multiple files, sometimes even multiple folders.
A counter is the simplest canonical example of a reducer. Often you need to have a combination of state and reducers plus some 'side-effects' - async functions can can be dispatched (thunks) or that can execute in response to the synchronous actions that go through the store, often as middleware. We have that covered! Oh, and there's no middleware to add, all the functionality is baked into thecreateStore that we saw earlier.
Let's look at something more complex, the state for a 'todo' app which needs to handle async fetching of data from a REST API. We want to only fetch data when we don't already have it and what we need to fetch will depend on the route we're on - if we go from a list view to a single-item view, we don't need to fetch that single item as we already have it, but if our first view is the single item we want to fetch just that, and then fetch the list if we navigate in the other direction.
Also, we want to be able to display loading state in the UI so we need to be able to indicate when we've requested data and when it's loaded. This is where the Redux approach shines - converting asynchronous changes to predictable and replayable synchonous state updates
The state part of this example is just a more complex but still typical example of immutable, Redux-like, state. But as well as defining actions as reducers, we can also define effects. These can also be dispatched just like the reducer-generated actions, but they also act as hooks so that when an action has been dispatched, if an effect exists with the same name, that will be called automatically.
import{createModel,RoutingState}from'@captaincodeman/rdx';import{Store}from'../store';// we're going to use a test endpoint that provides some ready-made dataconstendpoint='https://jsonplaceholder.typicode.com/'// this is the shape of a single TODO item that the endpoint providesexportinterfaceTodo{userId:numberid:numbertitle:stringcompleted:boolean}// this is the shape of our todos state in the store, having it strongly// typed saves some mistakes when we access or update itexportinterfaceTodosState{entities:{[key:number]:Todo}ids:number[]selected:numberloading:boolean}exportdefaultcreateModel({// our initial model statestate:<TodosState>{entities:{},ids:[],selected:0,loading:false,},// our state reducersreducers:{// select indicates the selected todo id, it will be called when we go// to a route such as /todos/123select(state,payload:number){return{ ...state,selected:payload}},// request indicates that we are requesting data, so it sets the loading// flag to truerequest(state){return{ ...state,loading:true};},// received is called when we have recieved a single todo item, it adds// it to the state and clears the loading flagreceived(state,payload:Todo){return{ ...state,entities:{ ...state.entities,[payload.id]:payload,},loading:false,};},// receivedList updates the state with the full list of todos, as well// as adding the todos to the entities it also stores their order in// the ids state, for listing them in the UIreceivedList(state,payload:Todo[]){return{ ...state,entities:payload.reduce((map,todo)=>{map[todo.id]=todoreturnmap},{}),ids:payload.map(todo=>todo.id),loading:false,};},},// our async effectseffects(store:Store){// reference the dispatch method, which is used in multiple effectsconstdispatch=store.getDispatch()// return the effect methods so they are wired up by the middlewarereturn{// after a todo is selected, we check if it is loaded or not// if it isn't loaded we dispatch the 'request' action followd by// the 'received' action. In real life we'd handle failures using a// 'failed' action to record the error message (for use in the UI).asyncselect(payload){conststate=store.getState()if(!state.todos.entities[state.todos.selected]){dispatch.todos.request()constresp=awaitfetch(`${endpoint}todos/${payload}`)constjson=awaitresp.json()dispatch.todos.received(json)}},// load is called to load the full list, whenever we hit the list// view URL but we avoid re-requesting them if we already have the// dataasyncload(){conststate=store.getState()if(!state.todos.ids.length){dispatch.todos.request()constresp=awaitfetch(`${endpoint}todos`)constjson=awaitresp.json()dispatch.todos.receivedList(json)}},// not only can we listen for our own dispatched actions (such as// the 'select' effect above) but we can also listen for actions from// other store state branches. In this case, we are interested in the// route changing and for the views that affects todos, we can then// dispatch the appropriate actions which will cause data to be loaded// if required (see effect methods above)'routing/change':asyncfunction(payload:RoutingState){switch(payload.page){case'todos-view':dispatch.todos.load()breakcase'todo-view':dispatch.todos.select(parseInt(payload.params.id))break}}}})})
Yes, it's more code than the counter model, but it's a lot less code to write than the Redux equivalent and it contributes less to the JS bundle for your app.
Note that the effects are dispatchable just like the reducers and they show up in the DevTools just the same. In the example above, callingdispatch.todos.select(123) would dispatch an action that would hit the reducer andthen the effect of the same name. Whereas callingdispatch.todos.load() would still dispatch an action but only run the effect (as there is no matching reducer).
We can also listen for actions dispatched from other state models, in both the reducers and effects functions. We've seen how this is done to listen for route changes but there are often cases where we may want to act on our local state based on some other dispatched action. As an example, we could clear data from the store when the auth model dispatches a signout action:
exportdefaultcreateModel({ state,reducers:{// ... existing model reducers// when user signs out, remove data from state'auth/signout':(state)=>{return{ ...state,data:[]}}}})
About
Like Redux, but smaller
Topics
Resources
Code of conduct
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Contributors3
Uh oh!
There was an error while loading.Please reload this page.