Rethinking the Redux API

October 05, 2019 0 Comments

Rethinking the Redux API

 

 

I really like Redux. I Love its ideas. The reducers for example - small pure functions that apply changes without side effects are nice way to model the mutations in the state. Redux also teaches us to use the one-direction data flow which makes our apps more predictable and stable. These two things fits well for what we are doing on the front-end - building user interfaces.

Of course there is nothing perfect and Redux as every other library has its own problems. In this article I want to explore some ideas for new APIs that will help solving the problems that I encounter. I’ll be happy to see your comments below.

Too much boilerplate

If you ever worked on a big application that uses Redux you probably know about the “too much boilerplate” problem. The usage of the Redux pattern involves a good amount of definitions. We need a new constant and a creator for every action. That’s how we trigger the state mutations in the body of our reducers and later the re-rendering of the UI. We also have to write selectors when we need access to the data which I don’t mind but some people find it unnecessary. I’ll probably anyway do it because the selector is giving me a small reusable getter and I like that.

But let’s see an example. Consider the following login form component:

function LoginForm({ authenticated, login }) { const username = useRef(null); const password = useRef(null); if (authenticated) { return 'Hey!'; } function onSubmit(e) { e.preventDefault(); login(username.current.value, password.current.value); } return ( <form onSubmit={ onSubmit }> <input type='text' name='username' ref={ username }/> <input type='password' name='username' ref={ password }/> <button type='submit'>login</button> </form> )
}

We have a clear input of a boolean flag (authenticated) and a function (login). We call the function with the username and password that the user typed and if the credentials are correct we display Hey!.

Now the Redux bit:

const LOGIN = 'LOGIN'; const isAuthenticated = (state) => { return state.authenticated;
} const ConnectedComponent = connect( state => ({ authenticated: isAuthenticated(state) }), dispatch => ({ login: (username, password) => dispatch({ type: 'LOGIN', username, password }) })
)(LoginForm); const initialState = { authenticated: false
}
const reducer = (state = initialState, { type, ...payload }) => { switch(type) { case LOGIN: const { username, password } = payload; if (username === 'correct' && password === 'one') { return { authenticated: true }; } } return state;
} const store = createStore(reducer); const App = () => ( <Provider store={store}><ConnectedComponent /></Provider>
);

If we render the <App /> component and we type correct for username and one for password we'll see the text Hey! on the screen. You can try this example here.

We have to define a couple of things to drive the form. And we didn’t even cover all the permutations. You can imagine what happens in a real life scenarios where we need to make an async call, pending and error state of the form. We need new actions and new cases in the reducer.

If I have to keep the functionality but come with a new API I would first try to eliminate the existence of the LOGIN constant. We need it on two places - action creator and reducer. What if we do both in a single line:

const actionCreator = state.mutate(reducer);

or in the context of our example:

const login = state.mutate((currentState, payload) => { const { username, password } = payload; if (username === 'correct' && password === 'one') { return { authenticated: true }; } return currentState;
});

state.mutate is a function that accepts a reducer and returns an action creator. The mutation still happens and we still have a reusable function that triggers the process. The difference between this approach and what Redux actually provides is that here we are triggering only one reducer case while in Redux a single action triggers all the reducers and all of their cases. This could be solved with the good old function composition.

const triggerLoginProcess = parallel(login, hideAd, showLoading);

Where parallel is a helper for running functions by passing same arguments to them.

In the end what we’ll probably have are just functions. We can compose/use them in a lot of ways. They are easy to transfer because they don't have dependencies. This approach will eliminate the need of constants because the action creator and the reducer are bundled together.

By default Redux can’t really handle side effects. There are some solutions that use the action creator to execute logic but as soon as this logic is asynchronous it becomes weird to do it there. We then have libraries like redux-saga which deal with side effects parallel to the work of Redux. Those solutions usually involve using a middleware and all the business logic is shifted away from the Redux flow.

It is of course debateable if Redux should handle side effects or not but I would love to have API for that.

When we need to perform a side effect what we usually write is some code that waits for certain action and once that action is fired we let the side effect happen. When we are done with it we dispatch another action to change state or continue with the application flow.

user clicks something | dispatch action | side effect | dispatch action | re-render

If we follow the idea with the mutate method we may provide a solution for side effects too.

const sideEffect = async (stateValue, payload) => { // async login here
}
const doSomething = state.pipe(sideEffect).mutate(reducer);

We still have a single function (doSomething) that triggers the process. No constants or action creators. That function will trigger our side effect and when it is done will continue with the mutation.

From here we can go further and implement other patterns from the functional programming. We may end up using something like this:

const login = state .filter(({ authenticated }) => !authenticated) .map(async (stateValue, payload) => { const { username, password } = payload; const user = await service.login(username, password); return user ? true : false; }) .mutate((currentState, success) => { return { authenticated: success }; })

which is basically saying

1. If you are not logged in.
2. Try to authenticate the user and if successful map the state value to "true" otherwise to "false"
3. Mutate the "authenticated" flag with the value returned by the side effect.

And again if we want to execute multiple things we can just use function composition.

Selectors

There is only one thing that bugs me when using selectors in Redux. They have a dependency on the store. This means that they must be used close to API that provides access to it. That is also the reason why we usually use selectors within the connect function.

I like how redux-saga works. We don’t need the state. We just import the selector and use it. We may come up with similar experience:

const selector = state.map(stateValue => stateValue.field);

Even further we can again define helper methods like filter or reduce which will make the data normalization easier.

Conclusion

I am thinking a lot about such APIs recently. Maybe because I’m interested in FRP but I find them easier to read, test, consume. I’m already working on a library that provides those APIs but that’s a subject of another article. If you are interested stay tuned. I’ll announce it very soon here and on my twitter feed.


Tag cloud