Skip to content

Instantly share code, notes, and snippets.

@tmkelly28
Last active July 6, 2022 15:35
Show Gist options
  • Save tmkelly28/d2f20b42272ac780de31ea6a734ef8c3 to your computer and use it in GitHub Desktop.
Save tmkelly28/d2f20b42272ac780de31ea6a734ef8c3 to your computer and use it in GitHub Desktop.

Part 5. Connecting a React component to the store

Let's return to our original task now: we want to make it so that our React component will use the state from the store instead of managing it on its own. When we update the state within the store (using dispatch), this should do the same thing that setState does.

That being said, React doesn't give us a way to replace state and setState. If we want a component to re-render, we need to use setState - it's the only way.

This could be tricky, so let's take it step by step.

First, let's make it so that this.state is initialized to be the result of saying store.getState(). This way, we at least start with the state on the component initialized to be the state inside the store.

import React from 'react';
import store from '../ourStore';

class Counter extends React.Component {
  
  constructor () {
    super();
    this.state = store.getState(); // this.state starts out as the state inside the store
    this.handleClick = this.handleClick.bind(this);
  }
  
  handleClick () {
    this.setState({ counter: this.state.counter + 1 })
  }
  
  render () {
    return (
      <div>
        <h3>The count is: { this.state.count }</h3>
        <button onClick={this.handleClick}>Increase</button>
      </div>
    )
  }
}

Now the initial state in our component is coming from the Redux store. However, this doesn't help us if the state in the store changes. However, remember that there's a way to do something every time the state inside the store changes - we can register a listener with store.subscribe!

Let's register a listener so that every time the state inside the store changes, we merge it into this.state in our React component. We'll make the subscription when the component mounts.

import React from 'react';
import store from '../ourStore';

class Counter extends React.Component {
  
  constructor () {
    super();
    this.state = store.getState();
    this.handleClick = this.handleClick.bind(this);
  }
  
  componentDidMount () {
    // When the state in the store changes, set it here
    // Because we're using setState, the component will also re-render once the new state is set
    store.subscribe(() => this.setState(store.getState());
  }
  
  handleClick () {
    this.setState({ counter: this.state.counter + 1 })
  }
  
  render () {
    return (
      <div>
        <h3>The count is: { this.state.count }</h3>
        <button onClick={this.handleClick}>Increase</button>
      </div>
    );
  }
}

This could work out for us - remember what Store.prototype.dispatch looks like:

Store.prototype.dispatch = function (action) {
  // get the new state object by invoking the reducer with the current state and the action,
  // and make the new state object our "currentState"
  currentState = reducer(currentState, action);
  
  // invoke all callbacks that have been registered (with store.subscribe)
  listeners.forEach(callback => callback());
};

With this in mind, let's walk through what happens when we first render this component:

  1. The this.state of the component is initialized to be our store's initial state.
constructor () {
  super();
  this.state = store.getState(); // { count: 0 }
  this.handleClick = this.handleClick.bind(this);
}
  1. The component initially renders. Because this.state.count is initialized at 0, the component renders that "The count is: 0".
render () {
  return (
    <div>
      <h3>The count is: { this.state.count }</h3> {/* 0 */}
      <button onClick={this.handleClick}>Increase</button>
    </div>
  );
}
  1. The component's componentDidMount hook executes, which registers a callback with the store's internal listeners array.
componentDidMount () {
  store.subscribe(() => this.setState(store.getState());
}

Now, if at some point after this we dispatch an action with a type of "INCREMENT_COUNTER"...

store.dispatch({ type: "INCREMENT_COUNTER" })

...the following steps will occur:

  1. The store gets the new state object by invoking the reducer with the current state and the action, and makes the new state object our "currentState".
// currentState is initially { count: 0}
// action is { type: "INCREMENT_COUNTER" }
currentState = reducer(currentState, action); 
// currentState is reassigned to be { count: 1 }
  1. The store invokes all callbacks that have been registered (with store.subscribe).
// listeners is an array containing the callback we registered: [ callbackFn ]
// callbackFn in this case is our callback: () => this.setState(store.getState())
listeners.forEach(callback => callback());
  1. When the callback our Counter component registered is invoked, it performs a setState with the new state object (which we get by saying store.getState).
// this is now invoked!
// store.getState() returns: { count: 1 }
() => this.setState(store.getState())
  1. The this.state on our Counter component is set, and the component renders with the new state
render () {
  return (
    <div>
      <h3>The count is: { this.state.count }</h3> {/* 1 */}
      <button onClick={this.handleClick}>Increase</button>
    </div>
  );
}

So this appears to work out for us quite well! Now all we need to do is actually dispatch actions in our event handlers instead of directly using setState - this way, all of our changes will be reflected in the store, rather than just in the component. We can simply change our click handler like so:

handleClick () {
  store.dispatch({ type: "INCREMENT_COUNTER" });
}

Now, when we click the button, we will dispatch the increment action, which will cause the 4 steps we outlined above to happen.

(As a side note: a nice bonus that we get here is that we no longer need to say this.handleClick = this.handleClick.bind(this) - the handleClick method no longer needs to use the this keyword!

We're almost done, but there's one last thing we have to do. We need to clean up in case our component unmounts - that is, in case we ever stop rendering this component for any reason, and the DOM elements it creates are removed from the DOM. Right now, this is the only component in our app, so this is a hypothetical scenario, but it wouldn't be hard to imagine what it this might look like:

// hypothetically, if we could swap between our Counter component and SomeOtherComponent:

<div>
{ someCondition ? <Counter /> : <SomeOtherComponent /> }
</div>

If "someCondition" is true, we render the Counter component, but if it turns false, we will render something else, and our currently rendering Counter component will unmount.

Our problem is that, even after our Counter component goes away, our callback is still registered with the store. This means that if another component dispatches an action, our store will still invoke that callback, and a component that is no longer mounted will be trying to setState on itself. This causes a nasty error.

We can fix this - remember that store.subscribe returns a function that will "unsubscribe" a listener from the store.

Store.prototype.subscribe = function (callback) {
  listeners.push(callback);
  return function () {
    listeners = listeners.filter(cb => cb !== callback);
  }
};

We can use this, together with the componentWillUnmount lifecycle hook, and remove our listener whenever the component unmounts.

import React from 'react';
import store from '../ourStore';

class Counter extends React.Component {
  
  constructor () {
    super();
    this.state = store.getState();
    this.handleClick = this.handleClick.bind(this);
  }
  
  componentDidMount () {
    // We capture the function that store.subscribe returns,
    // and attach it to the component on a field called `this.unsubscribe`
    this.unsubscribe = store.subscribe(() => this.setState(store.getState());
  }
  
  componentWillUnmount () {
    // When our component unmounts, we simply have to invoke the "unsubscribe" function.
    // This removes our callback from the listeners array in the store!
    this.unsubscribe();
  }
  
  handleClick () {
    store.dispatch({ type: "INCREMENT_COUNTER" });
  }
  
  render () {
    return (
      <div>
        <h3>The count is: { this.state.count }</h3>
        <button onClick={this.handleClick}>Increase</button>
      </div>
    );
  }
}

And that's it! Our component is now "connected" to the store. Whenever we dispatch an action to our store, any components that we've set up like this will update their this.state, and re-render their JSX. While this adds a bit of overhead, we've gained a couple of advantages, some of them huge:

  1. The biggest advantage is that "state" in our application is no longer coupled together with our view hierarchy. This means that we no longer have to worry about React components managing some state that will need to be pushed up later because a component on another branch of our view hierarchy also wants it.
  2. We now have a single source of truth for all state in our application - as long as we are diligent about managing any important state within our store, we substantially reduce the risk of having "two ways of determining the same thing".
  3. We may not have to use .bind(this) as much if our click handlers only dispatch actions (and don't use this)!

To review, here's the finished product:

import React from 'react';
import store from '../ourStore';

class Counter extends React.Component {
  
  constructor () {
    super();
    this.state = store.getState();
    this.handleClick = this.handleClick.bind(this);
  }
  
  componentDidMount () {
    this.unsubscribe = store.subscribe(() => this.setState(store.getState());
  }
  
  componentWillUnmount () {
    this.unsubscribe();
  }
  
  handleClick () {
    store.dispatch({ type: "INCREMENT_COUNTER" });
  }
  
  render () {
    return (
      <div>
        <h3>The count is: { this.state.count }</h3>
        <button onClick={this.handleClick}>Increase</button>
      </div>
    );
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment