HenrikJoreteg / redux-bundler
- пятница, 26 января 2018 г. в 03:16:50
Compose a redux store out of smaller bundles of functionality
clarification: this is not an "asset bundler" like WebPack or Parcel, it's a way to minimize redux boilerplate by defining redux-related functionality into "bundles" and then compose them to create a redux store.
It's no secret that there's a lot of boilerplate when building redux applications. There are some tips for reducing it in the official documentation and there's an open issue with over 100 comments on the redux repo about how to handle it that is left largely unresolved.
I've been building redux apps for quite some time and some of you may have been introduced to it when I first blogged about it back in 2015. This library is how I build redux apps, I finally decided to open source it.
It lets you compose a larger redux app out what I call "redux bundles" that encapsulate related functionality. Usually, a bundle includes a reducer, some action creators, and some selectors.
This isn't a toy project. I'm currently using this library in the three different production apps that power my online donation platform, Speedy. This also builds on some of the ideas that were originally conceived and battle-tested when I was helping Starbucks re-platform and build their shiny new PWA. The point is this is actually how I build things with Redux, and given the lack of "solutions" to the boilerplate issue, I decided to share it.
There's a ton more that needs to be documented, but i've open sourced a small sample application the source is here and it's deployed here so you can see how it all works when it's up and running.
File size will vary drastically depending on how much of the included capabilities you add, but as a rough guide the sample application was just thrown together using Parcel. It doesn't do tree-shaking or code-splitting. So it includes the application code, Preact, Redux, Redux-bundler (and all the optional bundles most of which aren't used), a local indexedDB powered caching support in a single JS file that ends up at ~18.5kb min + gzip. Which, although it could be better, is small enough for my tastes and really isn't too bad given that it's a full-fledged set of application tools.
Suppose you want to track some state related to the current user in your app.
Historically redux developers would spread this out across several files maybe add some action constants to one file, a reducer, somewhere else, perhaps a set of selectors in another file, and action creators in yet another.
With redux-bundler you create a single file perhaps in "/bundles/user.js" that looks something like this:
import { createSelector } from 'redux-bundler'
export default {
name: 'user',
getReducer: () => {
const initialState = { loggedIn: false, name: null }
return (state = initialState, { type, payload }) => {
if (type === 'USER_LOGGED_IN') {
return Object.assign({}, user, { loggedIn: true, name: payload })
}
return state
}
},
selectUserState: state => state.user,
selectIsLoggedIn: createSelector(
'selectUserState',
userState => userState.loggedIn
),
doLogin: () => ({ type: 'USER_LOGGED_IN' })
}
In this way you group related reducers, selectors, action creators. Redux-bundler then takes bundles like this and combines them all into a store and pre-binds and attaches everything to the redux store itself.
For example, simply by naming a function on the exported object selectIsLoggedIn
redux-bundler when given this bundle with add a method to the store itself that can call without arguments like this: store.selectIsLoggedIn()
that returns the result of that selector given the current redux state.
Similarly, action creators start with do
and get pre-bound and attached to the store so all you have to do is call store.doLogin()
to dispatch the action creator on the store.
The thing is, rather than importing selectors, constants, and binding action creators all over the place you can now just connect()
to inject it as a property just by using its name as a string like this:
import { connect } from 'redux-bundler-preact'
const MyComponent = ({isLoggedIn, doLogin}) => (
<div>
{isLoggedIn && (
<p>You are logged in!</p>
)}
{!isLoggedIn && (
<button onClick={doLogin}>Click to log in!</button>
)}
</div>
)
export const connect(
'doLogin',
'selectIsLoggedIn',
MyComponent
)
Things to note about the example:
selectIsLoggedIn
selects and passes a prop named isLoggedIn
(not SelectIsLoggedIn
because that'd be weird).doLogin
doesn't need to be bound to the store in any way, because it was already pre-bound when it was attached to the store so the function passed as a prop is already ready to use and it will just do what you'd expect.connect()
will throw an error if you try to connect something that doesn't exist on the store.mapStateToProps
or mapDispatchToProps
function to pass to connect()
connect()
method here along with a <Provider />
component for Preact live in this repo. I have not written bindings to React yet, because... well I just haven't. Most of the complexity of getting state deltas etc is already part of redux-bundler so writing those bindings would not be hard, mostly just copying what I did for preact for react.This approach of consolidating everything on the store actually enables some interesting things.
select
.react
instead of select
that will be evaluated on a regular basis and can return actions to trigger in response. This enables really, really interesting patterns of being able to recover from failure and retrying failed requests, etc. The level of reliability that can be achieved here is very powerful especially for use in PWAs that may well be offline or have really poor network conditions.connect()
doesn't have to do any dirty checking, so the binding code becomes very simple.localStorage.debug
to a truthy value) the store instance is bound to window
allowing console debugging of all your selectors, action creators via the JS console. For example you can type stuff like store.selectIsLoggedIn()
to see results or store.doLogout()
to trigger that action creator even if you don't have UI built for that yet.This is another one of the chief complaints people have with redux. They eventually feel like redux-thunk
doesn't suit their needs. This generally happens once they need to do something more complex than simple data fetches. Solutions like redux-loop or redux-saga attempt to solve this issue. I've never liked either one or any other solution that I've seen, for that matter. They're generally way more complicated than redux-thunk and in my opinion, nearly impossible for beginners to grok.
Let's take a step back. Many developers, if using react will use component life-cycle methods like componentDidMount
to trigger data fetches required by that component. But this sucks for many reasons:
The point I'm trying to make is that coupling data fetches to a component being visible, or even to a certain URL in your app isn't ideal.
What you're really trying to do is define a set of conditions that should lead to a new data fetch. For example you may want to fetch if:
/reports
in the pathname.Good luck writing that with simple procedural code!
Part of the appeal of react, as a movement, was to move toward a more reactive style of programming. Yet, most of our data-related stuff is very simplistic.
What we want, is our app to behave like a spreadsheet. I wrote about this in a post about reactive programming.
What if we let the current state of the app determine what should happen next? Instead of manually triggering things, what if a certain state could cause an action creator to fire? All of a sudden we can describe the conditions under which a data fetch should occur. We don't need better async solutions for redux, thunk is fine, what we need is a way to trigger "reactions" to certain state.
redux-bundler includes a pattern for this. Bundles can include what I call "reactors", which are really just selector functions. They can have dependencies on other selectors, and get passed the entire current state, just like other selectors. But if they return something it gets dispatched. This is all managed by redux-bundler. If your bundle includes a key that starts with react
it will be assumed to be a reactor. From a reactor you can check for whatever conditions in your app you can dream up and then return the action you want to dispatch. And, to be consistent with the decoupled philosophies, you can return an object containing the name of the action creator you want to trigger and the arguments to call it with.
As an example, I like to make a bundle that just manages all the redirects in my app. Here's an an abbreviated version from an actual app:
import { createSelector } from 'redux-bundler'
const publicUrls = ['/', '/login', '/signup']
export default {
name: 'redirects',
reactRedirects: createSelector(
'selectIsLoggedIn',
'selectPathname',
'selectHasNoOrgs',
(isLoggedIn, pathname, hasNoOrgs, activeOrgHasBasicInfo) => {
if (isLoggedIn && publicUrls.includes(pathname)) {
return { actionCreator: 'doUpdateUrl', args: ['/orgs'] }
}
if (!isLoggedIn && pathname.startsWith('/orgs')) {
return { actionCreator: 'doUpdateUrl', args: ['/login'] }
}
if (hasNoOrgs && pathname === '/orgs') {
return { actionCreator: 'doReplaceUrl', args: ['/orgs/create'] }
}
// remove trailing slash
if (pathname !== '/' && pathname.endsWith('/')) {
return { actionCreator: 'doReplaceUrl', args: [pathname.slice(0, -1)] }
}
}
)
}
Now I have one unified place to see anything that could cause a redirect in my app.
bundles
with one bundle per fileindex.js
file in bundles
to export the result of composeBundles()
, the resulting function takes a single argument which is any locally cached or bootstrapped data you may have, and returns a redux store. This is also useful for passing settings or config values to bundles that are dynamic as you see with the cachingBundle
and googleAnalytics
below:
import { composeBundles, cachingBundle } from 'redux-bundler' import config from '../config' import user from '/user' import other from './other' import googleAnalytics from './analytics' export default composeBundles( user, cachingBundle({ version: config.browserCacheVersion }), other, googleAnalytics(config.gaId, '/admin') )
select
such as selectAppTime
.do
such as doLogin
.thunk
middleware. Main difference being that everything is passed as a single argument object and one of the arguments passed is the store
itself. It passes {dispatch, store, getState}
plus anything you've explicitly set as extraArgs if any of your bundles define them.namedActionMiddleware
that lets you dispatch something that looks like: {actionCreator: 'doTheThing', args: ['hi']}
. And it will call the right action creator with the arguments you pass. Very likely you'll never use this directly.Things bundles can contain:
bundle.name
The only required attribute your bundle should supply. This will be used as the name of any exported reducer.
bundle.reducer
or bundle.getReducer()
If you export an item called reducer
it is assumed it's a ready-to-user redux reducer. Sometimes you need to dynamically configure something like initialData
in these cases a bundle can supply a getReducer
function instead that will return a reducer. This can be useful for any setup you may need to do, like defining initialState, or whatnot.
bundle.selectX(state)
Anything you attach that starts with select
such as selectUserData
will be assumed to be a selector function that takes the entire state object selects what you want out of it. This supports any function that takes the entire store state and returns the relevant data. If you use the createSelector
method exported by this library, you can create selectors whose dependencies are string names of other selectors. This allows for loose coupling between modules and means that you never have to worry about creating circular imports when various selectors depend on each other. This is possible because as part of creating the store, the library will resolve all those names into real functions. This is powered by create-selector
bundle.doX
Similarly to selectors, if your bundle contains any keys that start with do
, such as doSomething
they'll be assumed to be action creators.
These will be bound to dispatch for you and attached to the store. So you can call store.doSomething('cool')
directly.
important: a slightly modified thunk middleware is included by default. So you always have access to dispatch
, getState
, and store
within action creators as follows.
const doSomething = value => ({ dispatch }) =>
dispatch({ type: 'something', payload: value })
Note that unlike standard thunk that uses positional arguments, this passes just one object containing dispatch
, getState
, and any other items included by bundles that define extraArgs
.
bundle.reactX(state)
Reactors are like selectors but start with the word react
. They get run automatically by redux-bundler whatever they return gets dispatched. This could either be an object: {type: 'INITIATE_LOGIN'}
or it could be a named action creator like: {actionCreator: 'doInitiateLogin', args: ['username']}
.
This allows a simple, declarative way to ask questions of state, via selectors to trigger an effect via action reators without the need to introduce new approaches to deal with effects.
important: It is easy to make infinite loops. Make sure that any action triggered by a reactor, immediately change the conditions that caused your reactor function to return something.
bundle.getExtraArgs(store)
If you define this function it should return an object containing items you wish to make available to all action creators of all bundles.
Commonly this would be used for passing things like api wrappers, configs, etc.
important: this function will be called with the store. This allows you to do things like create API wrappers that automatically handle authorization failures to trigger redirects, etc.
bundle.init(store)
This will be run once as a last step before the store is returned. It will be passed the store
as an argument. This is useful for things like registering event listeners on the window or any other sort of initialization activity.
For example, you may want redux to track current viewport width so that other selectors can change behavior based on viewport size. You could create a viewport
bundle and register a debounced event listener for the resize
event on window, and then dispatch a WINDOW_RESIZED
action with the new width/height and add a selectIsMobileViewport
selector to make it available to other bundles.
This borrows from Django's "batteries included" approach. Where you don't have to use any of this stuff, but a pretty complete set of tools required for apps is included out of the box.
None of which are added by default, but many of which you'll likely want.
urlBundle
: a complete redux-based url solution. It essentially binds the browser URL to redux store state and provides a very complete set of selectors and action creators to give you full control of browser URLs.createRouteBundle
: a configurable bundle that you provide pass an object of routes to and it returns a bundle with selectors to extract route parameters from the routes you define.appTimeBundle
: tracks current app time as part of state, it gets set any time an action is fired. This is useful for writing deterministic action creators and eliminates the need for setting timers throughout the app. They can just tie into "appTime".asyncCountBundle
: tracks how many outstanding async actions are occurring (by action type naming conventions).If you like this follow @HenrikJoreteg on twitter.