Managing State in a React Application

Just to recap where we left off.

We can:

  1. Fetch a post from the server and display it.
  2. Create a new post and send it to the server.
  3. Fetch a list of posts from the server and display them.

It’s all tested and checked in to GitHub here and all the tests are running in CircleCI here.

We just discovered a problem though. Although you can create a new post, it doesn’t show up in the list of posts unless you refresh the page.

This is the point where I would usually reach for Redux but I have an idea for a state management library that is more Rails-friendly and requires less boilerplate code. Will it work? Who knows! Maybe I’ll just end up rebuilding Redux.

I am going to try though.

I want something that will be familiar to Rails developers and use many of the same API conventions. I’ll try to follow the Rails ideal of convention-over-configuration and, where there is configuration already on the server, I’ll try to use that. Wish me luck!

Let’s start with a Store that can be accessed by all the connected components.

// posts/Store.test.jsx
describe('The post store', () => {
  test('is initially empty', () => {
    const store = new Store()
    expect(store.all).toEqual([])
  })
})
// posts/Store.js
export class Store {
  constructor() {
    this.all = []
  }
}

// There's one store accessible to whoever needs it.
export const store = new Store() 

If someone calls list(), the store should go fetch posts from the server.

describe('The post store', () => {
let store
server.send = jest.fn()
const post = {
id: 1,
title: 'The Title',
body: 'The body.',
}

beforeEach( () => {
store = new Store()
})

test('is initially empty', () => {
expect(store.all).toEqual([])
})

test('fetches the list of posts from the server', async () => {
server.send.mockReturnValue({posts: [post]})

const posts = await store.list()
expect(posts.length).toEqual(1)
expect(posts[0].title).toEqual('The Title')
expect(store.all).toEqual(posts)

expect(server.send).toBeCalledWith('/posts.json')
})
})
export class Store {
  constructor() {
    this.all = []
  }

  async list() {
    const {posts} = await list()
    this.all = posts
    return posts
  }
}

Now we’ll edit ConnectedList to get its posts from the store instead of directly from the server. No need to change the tests; they should still just work.

export default class ConnectedList extends React.Component {
  async componentDidMount() {
    const posts = await store.list()
    this.setState({posts})
  }
}

Our next trick is to notify subscribers when a post is added to the store.

  test('notify subscribers when a post is added to the store', async () => {
    const subscriber = jest.fn()
    store.subscribe(subscriber)

    store.addAndNotify(post)

    expect(subscriber).toBeCalled()
    expect(store.all[0]).toEqual(post)
  })
export class Store {
  constructor() {
    this.all = []
    this.subscribers = []
  }

  async list() {
    const {posts} = await list()
    this.all = posts
    return posts
  }

  addAndNotify(post) {
    this.all = [...this.all, post]
    this.notify()
  }

  // extract this to a class
  subscribe(fn) {
    this.subscribers.push(fn)
  }

  unsubscribe(fn) {
    this.subscribers = this.subscribers.filter(subscriber => subscriber !== fn)
  }

  notify() {
    this.subscribers.forEach(fn => fn())
  }
}

And now we can hook the ConnectedList component up as a subscriber to the store.

// posts/List.test.jsx
  test('updates the list when a post is added to the store', async () => {
    server.send.mockReturnValue({posts: []})

    const component = await displayConnected(<ConnectedList store={store} />)
    assert_select(component, '.post', 0)

    store.addAndNotify(post)
    component.update()
    assert_select(component, '.post', 1)
  })

I did a little tidying here to allow us to pass in the store as a property to make tests easier to isolate from one another.

And, finally, here’s the ConnectedList, now subscribing to changes in the store.

  async componentDidMount() {
    const posts = await this.store.list()
    this.setState({posts})
    this.store.subscribe(this.storeDidUpdate)
  }

  componentWillUnmount() {
    this.store.unsubscribe(this.storeDidUpdate)
  }

  storeDidUpdate() {
    const posts = this.store.all
    this.setState({posts})
  }

I’m going to stop showing all my tests from here on since you probably get the idea already and it’s TDDious to show every little change. I’ll share the test if there is something new and interesting but, otherwise, I’ll stick to the code. I am still TDDing behind the scenes of course and, if you are interested, you can see all the tests in the GitHub repository here.

There are a couple of loose ends though. I should make all the components use the store instead of doing their own thing.

I’ll start with ConnectedPost. I’ll make the store maintain a list of posts by id and return the local copy if it already has one.

// store.js
  async find(id) {
    let post = this.by_id[id]
    if(! post) {
      post = await fetch(id)
      this.addAndNotify(post)
    }
    return post
  }

  async list() {
    const {posts} = await list()
    this.addAndNotify(posts)
    return posts
  }


  addAndNotify(post_or_posts) {
    if(Array.isArray(post_or_posts))
      post_or_posts.forEach(post => this.add(post))
    else
      this.add(post_or_posts)
    
    this.all = Object.values(this.by_id)
    this.notify()
  }

  add(post) {
    this.by_id[post.id] = post
  }

Don’t forget we need to sort the posts by date.

  addAndNotify(post_or_posts) {
    // ...    
    this.all = Object.values(this.by_id).sort(by_created_at)
    // ...
  }


// sorting.js
export function by_created_at(a, b) {
  if(a.created_at === b.created_at) return 0
  if(a.created_at > b.created_at) return -1
  return 1
}

Now to add a create method to the store…

// posts/store.js
  async create(post) {
    post = await create(post)
    this.addAndNotify(post)
    return post
  }

…and finally, we can call it from the post Editor

async handleSubmit(event) {
event.preventDefault()

const {post} = this.state
await this.store.create(post)
}

…and our job here is done.

We’ll reflect on where we are tomorrow because, now, we’ve earned ourself a pint of IPA at The Broken Dock.