November 26, 2018

Introducing react-atom: a simpler way to manage state

Introducing react-atom: a simpler way to manage state

I recently published a state-management library for React.js called react-atom. It's inspired by atoms in reagent.cljs, ClojureScript's version of React. You can find react-atom on GitHub here. Try it out in CodeSandbox here (JS) and here (TS). Keep reading to see a minimal comparison between redux and react-atom.

If you use react.js at all, you've probably heard about the recently announced new feature proposal called the Hooks API. If not, I highly recommend you find some time to read up on them, learn about the motivation behind their creation, and try building something with them to get a feel for it. Hooks are going to drastically change the way we build react apps: no more extending React.Component, no more lifecycle methods, no more this, no more "higher-order components", no more "render props". Just functions. Functions all the way down. (Don't worry; you can still use all those things since the features are additive and won't break any existing functionality. But it seems hooks will be the officially endorsed "best practice")

This post isn't about the Hooks API, per se, but about a library built on Hooks that I recently published called react-atom. It's a state-management library for React (yes, another one). The basic idea behind it is the same as its predecessors like redux or mobx or what-have-you: you've got some data that is going to change over time, and you want various parts of your UI to be able to consume and display the latest state of that data and to be able to update it at will, without having to pass that data and those update functions down through several layers of components. All of these libraries let you do that. The main difference react-atom really offers is in the simplicity and ergonomics of its API. I'll try to demonstrate that difference briefly. Since most of my UI state management experience is with redux, I'll just use that as my reference point for comparison with react-atom.

A Counter App with React and Redux

Ah the counter. Just jump to the next section if you've used redux enough to already know what this is going to look like. Of course you wouldn't use redux for something as trivial as this, but I want to demonstrate just how much you need to do for something so trivial in redux.

the component

So here's a React function component:

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'

const App = ({count}) => {
    return (<p>The count is {count}</p>)
}

ReactDOM.render(<App />, document.getElementById('root'))

the reducer

We we need to get the count from somewhere. So let's create a redux store. We just need a "reducer" (fancy word for "pure function that computes state") with some initial state.

// ./src/state/index.js
import {createStore} from 'redux'

const countInitialState = 0;

const countReducer = (state = countInitialState, action) => {
    switch(action.type) {
      default: return state
    }
}

export const store = createStore(countReducer);

Cool, now we just need to hook it up to our component. I'll skip showing how to manually use store.subscribe and just use the connect higher-order component from react-redux since that's more conventional. store.subscribe requires using a React.Component class component anyways (ew).

connecting the component to the store

connect takes a function that takes two functions, conventionally named mapStateToProps and mapDispatchToProps, and returns another function that takes your React component and then return a new React component that is subscribed to the redux store. Like so:

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {connect} from 'react-redux'
import {store} from './state'

const App = ({count}) => {
    return (<p>The count is {count}</p>)
}

const ConnectedApp = connect((count) => ({count}), null)(App)

ReactDOM.render(
  <ConnectedApp />,
  document.getElementById('root')
)

So we have our App component wrapped in another component that is connected to the store and will pass the return value of mapStateToProps to our App component on props. However, this won't quite work yet because we have to do one more thing to make the connection to the store work. We have to wrap our app in the Provider component from react-redux to make the store available to connected components. The Provider uses React's Context API to create the connection between "connected components" and the store. It just looks like this:

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {connect, Provider} from 'react-redux'
import {store} from './state'

const App = ({count}) => {
    return (<p>The count is {count}</p>)
}

const mapStateToProps = (count) => ({count})


const ConnectedApp = connect(
  mapStateToProps, 
  null
)(App)

ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
)

Okay. So now if we run our app, we'll see that "The count is 0". Cool. We wired up redux!

Now how do I change the count? There are a few more steps. We'll need to write some logic in the countReducer to handle a request to increment the count. We'll also need to create an action type constant and an "action creator" to generate the request to dispatch to the store to be processed by the reducer.

the action type and action creator

Basically, an "action type constant" is just a string that acts as a tag or identifier for our request to the reducer. An "action creator" is just a factory function that returns an object with the action type tag and any relevant payload the reducer needs to process the request; that . Here they are:

const INCREMENT = "count/increment"

export const incrementCount = () => ({ type: INCREMENT })

We need to export the action creator because we'll need it to dispatch the action it creates.

Now we need to enhance our countReducer to handle this increment action.

// ./src/state/index.js
import {createStore} from 'redux'

// ----------- Actions
const INCREMENT = "count/increment"

export const incrementCount = () => ({ type: INCREMENT })


// ----------- Reducer
const countInitialState = 0;

const countReducer = (state = countInitialState, action) => {
    switch(action.type) {
      case INCREMENT: return state + 1
      default: return state
    }
}

export const store = createStore(countReducer);

So, we just added an INCREMENT case to our switch statement. If the action type is count/increment then we add 1 to the state.

So how do we get our app to send this request to increment the count?

We need to dispatch an action.

dispatch the action to the store

So back to our App component. We'll set up the increment dispatch function in the connect function, on the mapDispatchToProps argument.

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {connect, Provider} from 'react-redux'
import {store, incrementCount} from './state'

const App = ({count}) => {
    return (<p>The count is {count}</p>)
}

const mapStateToProps = (count) => ({count})
const mapDispatchToProp = ((dispatch) => ({
  increment: () => dispatch(incrementCount()),
})

const ConnectedApp = connect(
  mapStateToProps, 
  mapDispatchToProp
)(App)

ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
)

Adding that mapDispatchToProps function makes it so that increment will now be available on props for our connected component. So we should destructure that off with count and use increment in an event handler somewhere so we can increment the count through the UI. Let's just add a button.

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {connect, Provider} from 'react-redux'
import {store, incrementCount} from './state'

const App = ({count, increment}) => {
  return (
    <div>
      <p>The count is {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  )
}

const mapStateToProps = (count) => ({count})
const mapDispatchToProp = ((dispatch) => ({
  increment: () => dispatch(incrementCount()),
})

const ConnectedApp = connect(
  mapStateToProps, 
  mapDispatchToProp
)(App)

ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
)

And with that, we're done. We can now increment the count by clicking a button. Let's see it all together.

the finished product: a react/redux counter app

// ./src/state/index.js
import {createStore} from 'redux'

// ----------- Actions
const INCREMENT = "count/increment"

export const incrementCount = () => ({ type: INCREMENT })


// ----------- Reducer
const countInitialState = 0;

const countReducer = (state = countInitialState, action) => {
    switch(action.type) {
      case INCREMENT: return state + 1
      default: return state
    }
}

export const store = createStore(countReducer);

// ----------------------------------

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {connect, Provider} from 'react-redux'
import {store, incrementCount} from './state'

const App = ({count, increment}) => {
  return (
    <div>
      <p>The count is {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  )
}

const mapStateToProps = (count) => ({count})
const mapDispatchToProp = ((dispatch) => ({
  increment: () => dispatch(incrementCount()),
})

const ConnectedApp = connect(
  mapStateToProps, 
  mapDispatchToProp
)(App)

ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
)

That was a non-trivial amount of work for a very trivial feature. Things get a lot more complicated in real apps, and lot's of libraries and design patterns have emerged to try to help manage the complexity, but they bring their own complexity as well.

Okay, let's see what this exact same stupid app looks like with react-atom

A Counter App with react-atom

With react-atom, we just create an Atom, define a function increment that swaps the Atom's current state with its next state, and pass the Atom to our custom React Hook useAtom within our function component to get its current state. Any time the state is swaped, the component will re-render. It really doesn't need any further explanation than that. Here's what it looks like.

// ./src/index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import {Atom, useAtom, swap} from '@dbeining/react-atom'

const stateAtom = Atom.of({count: 0});

const increment = () => swap(stateAtom, ({count}) => ({count: count + 1}))

const App = () => {
  const {count} = useAtom(stateAtom)
  
  return (
    <div>
      <p>The count is {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  )
}

ReactDOM.render(<App />,document.getElementById('root'))

We get the same functionality with less than half the code required by redux, and the code itself feels much closer to the problem at hand. Working with IO/async effects is just as simple. Just use swap in your async functions as needed. No need for "thunks" or "sagas" or "epics".

advantages of react-atom

The primary advantage I see in this little state-management library is its ergonomic, intuitive API enabled by the React Hooks API. There is very little to learn and there is no ceremony involved; you create an Atom with state, consume the state in your components with useAtom, and swap the state with pure functions. Done (in most cases).

Another advantage is that it's just a lightweight (1.6kb gzipped) abstraction over React Hooks. Nothing else. Hooks are the future of React and they're going to change the way we develop UIs. Having a robust state management library with a clean, hooks-based API will be helpful for making our global state-management logic follow the same style/convention as the rest of our app logic. useAtom will be a much more natural fit than connecting our components through higher order components and whatnot.

Additionally, react-atom is developed in TypeScript, so every release is published with correct, high quality typings (in case you're into that). If you do use TypeScript with React, I think you'll be pleasantly surprised by how much less verbose it is to correctly type a react-atom app than it is for a redux app.

Prior Art

react-atom was heavily plagiarized from inspired by atoms in ClojureScript's version of React called reagent. They work almost exactly the same, except that react-atom's API is modified slightly to accomodate React naming conventions regarding custom hooks.

Try Out react-atom for Yourself

I've set up a CodeSandbox for anyone who want to tinker with react-atom without having to set up a project/IDE. You can try out the JavaScript demo here and the TypeScript Demo here.

You can find the source code in the GitHub repo here (give react-atom a star!). API documentation lives here.


Post Script

Edit 11-27-2018: Dan Abramov kindly responded to my tweet for his thoughts on react-atom. He made me aware that Andrew Clark (@acdlite) just published a feature proposal for the Context API that would make it similar to react-atom. I'll need to study up on the issues the proposal is trying to solve and see where the feature goes, but as of now, I think react-atom may still be able to offer additional features not built into the proposed additions to Context. Or maybe not ;)