DLM Tutorials

Part 4

RepoStarter CodeFinished Code
Rails API04_start04_end
React Client04_start04_end

If you’d like to code along with me here, clone down the repos to your machine and checkout the 04_start branch in each. For rails, boot up your server like this: rails s -p 3001. For react, yarn start should do it.

In this part of the series, we’ll be introducing redux to our application. We’ll be approaching the following tasks:

  • Add Redux, react-redux, redux-devtools-extension & Redux-thunk dependencies to project
  • configure our store to utilize the Redux devtools and Redux Thunk middleware
  • Wrap our App component in to Provider tag from react-redux
  • move the fetch requests in our container components to action creators
  • move container component state (with the exception of our form component state) to the redux store
  • use the connect HOC to connect our components to the store’s state and dispatch

Add dependencies to your Project

First, lets add redux, react-redux and redux-thunk. These libraries will be working together to allow us to implement the unidirectional flux data flow pattern.

yarn add redux react-redux redux-devtools-extension redux-thunk

Configure the store to utilize the Redux devtools and Redux Thunk middleware

In order to create the store and set it up, we’ll need to access some methods from redux and react-redux. When we’re building a more complicated react redux application, we need to ensure that our application can support multiple reducers. To do this, we’ll want to use the combineReducers method from redux so that we can create a rootReducer that will encapsulate all of our different pieces of state into a single reducer that we can pass to createStore. Before we get started, let’s take a look at the pieces of state that we’ll be tracking with separate reducers.

EventFull combineReducers diagram

This will leave us room to expand the state in the store as our application grows.

Eventfull combineReducers expanded

In order to create the store object in our application, we’ll be making use of the createStore() method from redux. Here’s the description of the createStore() method from redux.js.org:

EventFull createStore() docs on redux.js.org

To create our store, we’ll use our rootReducer as the first argument, but we’re also going to want to pass an enhancer function because we want to add the redux-thunk middleware to allow us to invoke dispatch within async promise callback functions (in our action creators). But, we also want to be able to visualize and debug redux in the browser, so we’ll also want to install the Redux DevTools Chrome extension and we’ll be importing a function from the redux-devtools-extension package.

If you check the docs on github, you’ll find this code for using the library with all of the default options:

import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'

const store = createStore(
  reducer,
  composeWithDevTools(
    applyMiddleware(...middleware)
    // other store enhancers if any
  )
)

We’ll be creating a file called store.js and exporting the store from the file.

touch src/store.js

And, because it’s redux-thunk middleware that we want to add for the app, and we’re going to be using combineReducers() to generate a rootReducer, this is what we need to do.

Then, we’ll add our imports from redux, redux-thunk and the redux-devtools-extension packages and create the store.

// store.js
import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunk from 'redux-thunk'
import rootReducer from './reducers/index'

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(thunk))
)

export default store

Before this will work, however, we’ll need to create our rootReducer.

mkdir src/reducers src/actions
touch src/reducers/index.js

Then, in our src/reducers/index.js file, we’ll want to use the combineReducers() method to create the rootReducer. For now, we can pass an empty object to combineReducers().

// src/reducers/index.js
import { combineReducers } from 'redux'

export default combineReducers({})

As we work, we’ll be adding reducers to the application and then adding key-value pairs to this object as we add new reducers to handle pieces of state in the store. You can read more about combineReducers and state design in the redux.js.org docs.

Wrap our App component in the Provider tag from react-redux

Next, we need to import the store from src/store.js and then wrap our App component in a Provider component (imported from react-redux) and we’ll pass the store as a prop to it:

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'

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

Once we’ve added the Provider tag and passed in the store object, we can boot up our dev server using yarn start and check out the dev tools in the browser.

Redux DevTools Screenshot

Use the connect Higher Order Component (HOC) to connect our components to the store’s state and dispatch

Transitioning to Redux will mean that most of our React component’s state will move to the store instead and access via mapStateToProps(). And, the places where we would be calling setState, we’ll instead be invoking an action creator that we’ve used mapDispatchToProps to connect to dispatch. I highly recommend reading through the guides on react-redux.js.org for the connect function.

As we approach the reduxification of our project here, we’ll start with GroupsIndexContainer. We’ll do each of the containers in the following stages:

  • introducing the necessary action creators
  • introducing corresponding reducers and reducer cases
  • Wrapping our container in connect so we can access state and dispatch actions

Overall, this is what the redux flow looks like when we’ve integrated redux-thunk middleware to allow for asynchronous actions (fetch requests). Thanks to Stephen Grider for the below diagram.

Redux Thunk Data flow diagram

Before we hop into the code here, I want to talk through Redux middleware. When we configured our store earlier, we invoked the applyMiddleware() function from redux. This function is used to connect a series of middleware functions to the redux flow which begins when an action is dispatched. Here’s what the guides on redux.js.org have to say about the applyMiddleware() function’s arguments:

applyMiddleware() function arguments

The main thing to keep in mind here is that middleware is away of extending the functionality normally triggered by the dispatch method. Every time we invoke dispatch all of our middleware functions are invoked with the getState and dispatch methods as arguments, allowing us to access the store’s state and trigger additional actions via dispatch if we wish. We can also prevent the dispatch from going through by not invoking next within the middleware function. A key part of the description to note is that all redux middleware must conform to this signature:

;({ getState, dispatch }) => (next) => action

In this signature, the next function refers to the dispatch function of the next function in the middleware chain. If we’re at the last link in the chain, then next will refer to the real dispatch function that will update the store state. This is important to keep in mind in our case because we only have a single middleware, Redux Thunk. So, with this in mind, let’s take a look at the source code for redux-thunk as an example.

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument)
    }

    return next(action)
  }
}

const thunk = createThunkMiddleware()
thunk.withExtraArgument = createThunkMiddleware

export default thunk

So, the main thing that redux-thunk does is that it checks if the action that is dispatched is a function. If it is, it will invoke the function, passing in dispatch and getState as arguments (no extraArgument in our case because of how we imported). It will then return the return value of the action function. This will be useful to us later when we want to trigger a react router redirect after an action creator successfully updates the store.

Reduxifying the GroupsIndexContainer

This is what the component looks like currently.

import React, { Component } from 'react'
import GroupsList from '../components/GroupsList'

export default class GroupsIndexContainer extends Component {
  state = {
    groups: [],
    loading: true,
  }

  componentDidMount() {
    // we'd probably want to store the API_URL in an environment variable
    // so this would work in deployment as well but for now we'll hard code the hostname
    fetch('http://localhost:3001/groups', {
      method: 'get',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((groupsJson) => {
        console.log('groups', groupsJson)
        this.setState({
          groups: groupsJson,
          loading: false,
        })
      })
  }

  render() {
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        {this.state.loading ? (
          'loading spinner'
        ) : (
          <GroupsList groups={this.state.groups} />
        )}
      </section>
    )
  }
}

Starting with our list of groups, we’ll need to have an action creator for loading all of the groups. This action creator will be a thunk so that we’re able to invoke dispatch after we get the response from our API.

touch src/actions/groups.js

Inside of this file, we’ll create an actionCreator called fetchGroups that will return a function (called a thunk) that will be invoked with dispatch as an argument when the redux-thunk middleware kicks in.

Getting a sense of our state shape for groups

Before we set up our action creator, we need to think about how our state shape is going to change when we move to redux. One of the main changes we need to handle is the fact that the data in the store will persist beyond a particular react route being active. So, rather than always clearing out local state and reloading it when we revisit the route, we’re going to be thinking more about what data we currently have access to and making sure that our application works properly for all of our routes no matter what order we visit them in.

For example, visiting /groups/1 should work the same way whether you visit it for the first time straight from a link that was sent to you as it would if you visited / to view the groups index first and then navigated there. It should also work properly if we visit it again after leaving and coming back. (Visiting /groups/1 then going back to / and returning to /groups/1 again should not lead to the same events being displayed more than once).

When we’re thinking about state shape, one of the concerns to address is how we’re going to handle this situation of persisting fetched data in such a way that we know what we currently have access to and can thus avoid doing unnecessary fetch requests.

All of that said, it’s helpful to sketch out the shape of your state beforehand while thinking about these concerns. While the data in your store will change over time, it’s advisable to keep the shape of your state consistent. This will make the code that consumes your state much easier to manage. The shape of our state will determine the code we need to write within mapStateToProps to access the appropriate information. For example, if our state looked like this object below…

// sketch out of state object in store (for purposes of visualizing the shape)
{
  groups: {
    loadingState: 'notStarted' || 'inProgress' || 'loaded',
    groups: []
  }
}

Then we’d need to access groups like so:

const mapStateToProps = (state) => {
  return {
    loading: state.groups.loadingState
    groups: state.groups.groups
  }
}

So, if we wanted to avoid state.groups.groups we could call the property, list instead:

// sketch out of state object in store (for purposes of visualizing the shape)
{
  groups: {
    loadingState: 'notStarted' || 'inProgress' || 'successful',
    list: []
  }
}

Then we could do this:

const mapStateToProps = (state) => {
  return {
    loading: state.groups.loadingState
    groups: state.groups.list
  }
}

So, in our action creator, we need to be thinking about how our reducers are going to work as well. So, what type of action are we going to dispatch and which reducers are going to care about the action and how will they respond? How will the state shape be affected by the action? So, let’s start by building out the reducer so we can see how we’ll want to connect the two.

touch src/reducers/groups.js

Building the groupsReducer

When we put together a reducer, we need to handle for the initialState and add that as the default argument for state (our first argument to the reducer). When the app first boots, Redux will dispatch an empty action of type @@init, so we need all of our reducers to have a default argument for state so that the initial value for state can be set upon receiving that first dispatched action.

// src/reducers/groups.js
const initialState = {
  loadingState: 'notStarted',
  list: [],
}

export default function GroupsReducer(state = initialState, action) {
  return state
}

We also need to hook up this reducer to our root reducer.

// src/reducers/index.js
import { combineReducers } from 'redux'
import groupsReducer from './groups'

export default combineReducers({
  groups: groupsReducer,
})

Now, we can see what the state looks like in the redux dev tools.

Eventfull Redux DevTools with Groups showing

Awesome! Now we can see the groups in the store’s state. But, at the moment our list property points to an empty array. We’ll fix that next.

Building in the groups actions

In order to populate the groups.list, we’re going to need to move our fetch into the redux flow. To get this hooked into our fetch, we’ll want to think about what types of actions should affect the state in this part of our store. Also, since each of our actions will require a type property, we can create a file where we can keep track of the constants related to actions, we can use src/actions/index.js.

touch src/actions/index.js

The reason to store your action types as constants instead of just using the string literals as action types is so that we’ll actually get an error if we make a typo rather than just having an action that doesn’t do what we expect.

// src/actions/index.js
export const START_LOADING_GROUPS = 'START_LOADING_GROUPS'
export const SUCCESSFULLY_LOADED_GROUPS = 'SUCCESSFULLY_LOADED_GROUPS'

// for later
export const ADD_GROUP = 'ADD_GROUP'
export const FAILED_LOADING_GROUPS = 'FAILED_LOADING_GROUPS'

For now, we’re just going to add actions for starting to load groups and then successfully loading the groups.

// src/reducers/groups.js
import {
  START_LOADING_GROUPS,
  SUCCESSFULLY_LOADED_GROUPS,
  FAILED_LOADING_GROUPS,
  ADD_GROUP,
} from '../actions'
const initialState = {
  loadingState: 'notStarted',
  list: [],
}

export default function groupsReducer(state = initialState, action) {
  switch (action.type) {
    case START_LOADING_GROUPS:
      // spread out the previous state into a new object literal and set loadingState to "inProgress"
      return { ...state, loadingState: 'inProgress' }
    case SUCCESSFULLY_LOADED_GROUPS:
      return {
        list: action.payload,
        loadingState: 'successful',
      }
    default:
      return state
  }
}

Now that we’ve got our reducer ready to accept an action and respond appropriately, let’s create an action creator that uses a thunk to dispatch the appropriate action after we get a successful response from the api. We’ll dispatch two actions from this action creator, the first one will indicate that we’ve started loading groups (loadingState === 'inProgress') and the second one will indicate that we’ve finished loading them and we want to populate the store with the response.

// src/actions/groups.js
import { START_LOADING_GROUPS, SUCCESSFULLY_LOADED_GROUPS } from '.'

export const fetchGroups = () => {
  return (dispatch) => {
    dispatch({ type: START_LOADING_GROUPS })
    fetch('http://localhost:3001/groups', {
      method: 'get',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((groupsJson) => {
        dispatch({
          type: SUCCESSFULLY_LOADED_GROUPS,
          payload: groupsJson,
        })
      })
  }
}

Okay, so now that we’ve got our groupsReducer ready to handle actions and our fetchGroups action creator ready to allow dispatch for those actions, we need to upt it all together with connect().

Wrapping GroupsIndexContainer in connect()

In order to hook this up to our component, we need to wrap the GroupsIndexContainer in the connect() HOC. This will allow our GroupsIndexContainer to access the groups.list and groups.loadingState from the store via mapStateToProps and the fetchGroups action creator via mapDispatchToProps.

So, we’re going to need to change a few things in the component. To start, we’ll add connect and the mapStateToProps and mapDispatchToProps functions to pass as arguments. We’ll also need to remove the export default in front of the GroupsIndexContainer and move it to the bottom where we’re connecting the container to the store.

import React, { Component } from 'react'
import { connect } from 'react-redux'
import { fetchGroups } from '../actions/groups'
import GroupsList from '../components/GroupsList'

class GroupsIndexContainer extends Component {
  state = {
    groups: [],
    loading: true,
  }

  componentDidMount() {
    // we'd probably want to store the API_URL in an environment variable
    // so this would work in deployment as well but for now we'll hard code the hostname
    fetch('http://localhost:3001/groups', {
      method: 'get',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((groupsJson) => {
        console.log('groups', groupsJson)
        this.setState({
          groups: groupsJson,
          loading: false,
        })
      })
  }

  render() {
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        {this.state.loading ? (
          'loading spinner'
        ) : (
          <GroupsList groups={this.state.groups} />
        )}
      </section>
    )
  }
}

const mapStateToProps = (state) => {
  return {}
}

const mapDispatchToProps = (dispatch) => {
  return {}
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(GroupsIndexContainer)

Okay, so to fill in our connect functions, what information do we need access to from the store? Well, we need the list of groups we have stored, and we also need to know whether the loadingState is 'inProgress', 'successful', or 'failed' (which we’ll handle later on).

const mapStateToProps = (state) => {
  return {
    groups: state.groups.list,
    loadingState: state.groups.loadingState,
  }
}

In order to get the data into the store, so that we can deliver it into the component via mapStateToProps, we also need to add the ability to invoke our fetchGroups action creator and dispatch the appropriate actions via mapDispatchToProps. To make the distinction between the function we’re importing from src/actions/groups.js and the function that’s mapped to props in the component, let’s call the function we map to props: dispatchFetchGroups. We do this because invoking the function will trigger a dispatch of its return value.

Note, if we called the fetchGroups action creator function we imported from src/actions/groups.js, we would not end up affecting the store at all. The reason is that our action creator just returns an action but it doesn’t dispatch the return value. The purpose of mapDispatchToProps is to allow us to both invoke the action creator and dispatch the return value via a function mapped to props in the wrapped component.

const mapDispatchToProps = (dispatch) => {
  return {
    dispatchFetchGroups: () => dispatch(fetchGroups()),
  }
}

Now, we can remove the state from our container component, and replace our call to fetch in componentDidMount(), with a call to our dispatchFetchGroups prop.

import React, { Component } from 'react'
import { connect } from 'react-redux'
import { fetchGroups } from '../actions/groups'
import GroupsList from '../components/GroupsList'

class GroupsIndexContainer extends Component {
  componentDidMount() {
    this.props.dispatchFetchGroups()
  }

  render() {
    if (this.props.loadingState === 'notStarted') {
      return null
    }
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        {this.props.loadingState === 'inProgress' ? (
          'loading spinner'
        ) : (
          <GroupsList groups={this.props.groups} />
        )}
      </section>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    groups: state.groups.list,
    loadingState: state.groups.loadingState,
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    dispatchFetchGroups: () => dispatch(fetchGroups()),
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(GroupsIndexContainer)

So, to review the flow here, we’ve connected GroupsIndexContainer to the store in order to pass the groups list and loadingState properties as props (via mapStateToProps) and a dispatchFetchGroups method as a prop that will dispatch the action (a thunk) that will notify the store that we’re loading groups (by setting loadingState to 'in progress') and then dispatching an action to indicate that groups have been successfully loaded from the API. When the store’s state is updated, new props will be passed to GroupsIndexContainer via mapStateToProps triggering a re-render of the comonent, displaying the newly loaded list of groups.

GroupShowContainer

For the GroupShowContainer we’ll be reusing the same pattern, but it will be a bit more involved, because this component needs access both to groups and to events. If we’re in a situation where we’ve visited the root route first, loading all the groups in the process, all of the groups will be in the store. But, if we go straight to /groups/1 for example, none of the groups will be in the store. So, we need to give our container the ability to access the groups in the store, and to retrieve a particular group and map it to props. We also need to grab the events that belong to this particular group and add those events to the store as well.

For this reducer, we actually need to keep track of which groups have been loaded already. This is important because we’re going to store events from multiple groups in the store and we only want to display the groups that belong to a particular group within the GroupShowContainer. If we come back to this route again, we don’t want to add the same events to the store again. So, we need to keep track (in the store) of the groups for which we’ve already loaded events.

Getting a sense of the state shape for events

To model this, we can use an object in state called groupsLoaded that will have keys will be a group_id and the values would represent the progress of loading for that group. It will start as "inProgress" and move to "successful".

// sketch out of state object in store (for purposes of visualizing the shape)
{
  events: {
    groupsLoaded: {
      [group_id]: 'inProgress' || 'successful'
    }
    list: []
  }
}

If we structure the eventsReducer state in this way, we’ll be able to access the loading state for a particular group in mapStateToProps. We’ll also be able to map just the particular group that we want by pulling the match prop that React Router passes to our connected component.

const mapStateToProps = (state, { match }) => {
  const groupId = match.params.groupId
  let loadingState = state.events.groupsLoaded[groupId] || 'notStarted'
  return {
    loadingState,
  }
}

This way, we can check within our componentDidMount for the GroupShowContainer if the loadingState is "notStarted" and if it is, we can trigger an action creator to load the events for this group. If it’s not, we can skip invoking the action creator as we already have events in the store at that point.

componentDidMount() {
  if(this.props.loadingState === "notStarted") {
    this.props.dispatchFetchGroup(this.props.match.params.groupId)
  }
}

Later on, we could add a refresh button to this component that would trigger an additional call to the action creator to allow refreshing the events for this particular group. This way, we could see events that may have been added to the group after our initial fetch. This would require us to modify the reducer case slightly, however, as we would not want to inadvertently add the same events to our list again. Let’s say we had a reducer case that looked like this:

case SUCCESSFULLY_LOADED_GROUP_EVENTS:
  return {
    groupsLoaded: {
      ...state.groupsLoaded,
      [action.payload.group.id]: "successful",
    },
    list: state.list.concat(action.payload.events),
  };

As is, this would any preexisting events to the list again. To avoid this, we’d need to make sure that we filter out all events from the list with the same group_id as the one whose events we’re loading before we do the concat.

case SUCCESSFULLY_LOADED_GROUP_EVENTS:
  const { group, events } = action.payload;
  return {
    groupsLoaded: {
      ...state.groupsLoaded,
      [group.id]: "successful",
    },
    list: state.list.filter(event => event.group_id !== group.id).concat(events),
  };

This would allow us to ensure that we don’t add events to the list more than once and if we do fetch events for the group again, it will only add new events to the store.

Building the eventsReducer

Our reducer needs to handle two separate actions. One indicating that we’re starting to load a group, the other to indicate that we’ve successfully loaded the events and to add them to the list.

import {
  SUCCESSFULLY_LOADED_GROUP_EVENTS,
  START_LOADING_GROUP,
} from '../actions'

const initialState = {
  groupsLoaded: {},
  list: [],
}

export default function eventReducer(state = initialState, action) {
  switch (action.type) {
    case START_LOADING_GROUP:
      return {
        ...state,
        groupsLoaded: { ...state.groupsLoaded, [action.payload]: 'inProgress' },
      }
    case SUCCESSFULLY_LOADED_GROUP_EVENTS:
      return {
        groupsLoaded: {
          ...state.groupsLoaded,
          [action.payload.group.id]: 'successful',
        },
        list: state.list.concat(action.payload.events),
      }
    default:
      return state
  }
}

Creating the corresponding action creator

In order to get these actions going, we’ll need to add an action creator for fetchGroup that will take in the groupId and dispatch the appropriate actions at the appropriate times.

// src/actions/groups.js
import { START_LOADING_GROUP, SUCCESSFULLY_LOADED_GROUP_EVENTS } from './index'
// ...
export const fetchGroup = (groupId) => {
  return (dispatch) => {
    dispatch({ type: START_LOADING_GROUP, payload: groupId })
    fetch(`http://localhost:3001/groups/${groupId}`)
      .then((res) => res.json())
      .then((groupJson) => {
        dispatch({
          type: SUCCESSFULLY_LOADED_GROUP_EVENTS,
          payload: groupJson,
        })
      })
  }
}

This fetch will move from our GroupShowContainer’s componentDidMount to our action creator. We’ll then be invoking the action creator from componentDidMount. Finally, we’ll need to get our component connected.

Wrapping the GroupShowContainer in connect()

Now, we’ll rework the GroupShowContainer so it can consume the loadingState for this group’s events, the group itself and all of its events from the store. We’ll also use mapDispatchToProps to pull the fetch request into the redux flow instead of populating api data to local component state. This is what the component looks like now:

import React, { Component } from 'react'
import { Link } from 'react-router-dom'

export default class GroupShowContainer extends Component {
  state = {
    group: {},
    events: [],
    loading: true,
  }

  componentDidMount() {
    const groupId = this.props.match.params.groupId
    fetch(`http://localhost:3001/groups/${groupId}`)
      .then((res) => res.json())
      .then(({ group, events }) => {
        this.setState({
          group,
          events,
          loading: false,
        })
      })
  }

  render() {
    if (this.state.loading) {
      return <div>Loading Spinner</div>
    }
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        <h1 className="text-3xl font-bold text-center mb-8">
          {this.state.group.name}
        </h1>
        <p className="my-2">
          <Link to={`/groups/${this.state.group.id}/events/new`}>
            Add an Event
          </Link>
        </p>
        <div className="grid grid-cols-3">
          {this.state.events.map((event) => (
            <figure className="p-4 shadow">
              <img className="" alt={event.name} src={event.poster_url} />
              <h2>{event.name}</h2>
              <p>{event.start_time}</p>
              <p>{event.end_time}</p>
              <p>{event.location}</p>
              {/* Later we'll add a spoiler here to show the description */}
            </figure>
          ))}
        </div>
      </section>
    )
  }
}

We can start by importing connect and moving our default export to the bottom of the file below where we define mapStateToProps and mapDispatchToProps

import React, { Component } from 'react'
import { Link } from 'react-router-dom'
import { connect } from 'react-redux'

class GroupShowContainer extends Component {
  state = {
    group: {},
    events: [],
    loading: true,
  }

  componentDidMount() {
    const groupId = this.props.match.params.groupId
    fetch(`http://localhost:3001/groups/${groupId}`)
      .then((res) => res.json())
      .then(({ group, events }) => {
        this.setState({
          group,
          events,
          loading: false,
        })
      })
  }

  render() {
    if (this.state.loading) {
      return <div>Loading Spinner</div>
    }
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        <h1 className="text-3xl font-bold text-center mb-8">
          {this.state.group.name}
        </h1>
        <p className="my-2">
          <Link to={`/groups/${this.state.group.id}/events/new`}>
            Add an Event
          </Link>
        </p>
        <div className="grid grid-cols-3">
          {this.state.events.map((event) => (
            <figure className="p-4 shadow">
              <img className="" alt={event.name} src={event.poster_url} />
              <h2>{event.name}</h2>
              <p>{event.start_time}</p>
              <p>{event.end_time}</p>
              <p>{event.location}</p>
              {/* Later we'll add a spoiler here to show the description */}
            </figure>
          ))}
        </div>
      </section>
    )
  }
}

const mapStateToProps = (state) => {
  return {}
}

const mapDispatchToProps = (dispatch) => {
  return {}
}

export default connect(mapStateToProps, mapDispatchToProps)(GroupShowContainer)

mapStateToProps for the GroupShowContainer

Now, we’ll fill in the mapStateToProps so we can get access to the loadingState for this particular group’s events and then to the group and the events themselves.

const mapStateToProps = (state, { match }) => {
  const groupId = match.params.groupId
  let loadingState = state.events.groupsLoaded[groupId] || 'notStarted'
  return {
    group: state.groups.list.find((group) => group.id == groupId),
    events: state.events.list.filter((event) => event.group_id == groupId),
    loadingState,
  }
}

We pull the groupId out of the ownProps passed to the connected component that we have hooked up to a dynamic route with a groupId parameter. We do that by first destructuring the match property added by react route, and then pulling the groupId out of match.params.

For the group, we look through all the groups in state.groups.list to find the one whose id matched groupId and return it.

For events, we filter all of the events in state.events.list for those that have a group_id matching groupId.

For the loadingState, we look through the groupsLoaded object in state.events for the value of a property named groupId. This property is added when we begin fetching events for a particular group, so it won’t exist if this is the first time we’re loading events for this group. In this case, we set loadingState to "notStarted", otherwise we set it to whatever we have stored in groupsLoaded.

We can condense this logic into mapStateToProps so our component doesn’t have to go fishing through a bunch of data in the store to find what’s relevant.

mapDispatchToProps for the GroupShowContainer

For mapDispatchToProps, the GroupShowContainer needs to be able to dispatch the fetchGroup action to load the events for this particular group.

const mapDispatchToProps = (dispatch) => {
  return {
    dispatchFetchGroup: (groupId) => dispatch(fetchGroup(groupId)),
  }
}

So, we add a dispatchFetchGroup prop that holds a function that accepts groupId as a parameter and then invokes dispatch with the return value of fetchGroup(groupId) as an argument. Note, since we have a one-liner arrow function with no curly braces after the arrow, the return value of invoking dispatch here will be the return value of invoking dispatchFetchGroup. And, as a reminder, fetchGroup is an action creator so we also need to import it into this file. The fetchGroup function currently looks like this:

// src/actions/groups.js
// ...
export const fetchGroup = (groupId) => {
  return (dispatch) => {
    dispatch({ type: START_LOADING_GROUP, payload: groupId })
    fetch(`http://localhost:3001/groups/${groupId}`)
      .then((res) => res.json())
      .then((groupEventsJson) => {
        dispatch({
          type: SUCCESSFULLY_LOADED_GROUP_EVENTS,
          payload: groupEventsJson,
        })
      })
  }
}

Remember that redux-thunk will check if the action that we dispatch (which in this case is the function that accepts dispatch as a parameter) is a function. If it is, thunk will invoke it for us and pass in dispatch as an argument, returning our function’s return value in the process. So, if we wanted to chain on an additional promise callback after dispatching the action from our component via this.props.dispatchFetchGroup, we’d need to return a promise from our thunk function like so:

export const fetchGroup = (groupId) => {
  return (dispatch) => {
    dispatch({ type: START_LOADING_GROUP, payload: groupId })
    return fetch(`http://localhost:3001/groups/${groupId}`)
      .then((res) => res.json())
      .then((groupEventsJson) => {
        dispatch({
          type: SUCCESSFULLY_LOADED_GROUP_EVENTS,
          payload: groupEventsJson,
        })
      })
  }
}

Making the switch from local state to props in GroupShowContainer

Okay, so now that we’ve got the props we need from connect, let’s work them into the component. The main pieces of this are:

  1. replacing our fetch in componentDidMount with a call do dispatchFetchGroup
  2. replacing references to this.state with references to this.props
  3. Removing state altogether.

When we’re done, it should look like this:

import React, { Component } from 'react'
import { Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { fetchGroup } from '../actions/groups'

class GroupShowContainer extends Component {
  componentDidMount() {
    const groupId = this.props.match.params.groupId
    this.props.dispatchFetchGroup(groupId)
  }

  render() {
    if (this.props.loadingState !== 'successful') {
      return <div>Loading Spinner</div>
    }
    return (
      <section className="max-w-6xl w-11/12 mx-auto mt-16">
        <h1 className="text-3xl font-bold text-center mb-8">
          {this.props.group.name}
        </h1>
        <p className="my-2">
          <Link to={`/groups/${this.props.group.id}/events/new`}>
            Add an Event
          </Link>
        </p>
        <div className="grid grid-cols-3">
          {this.props.events.map((event) => (
            <figure className="p-4 shadow">
              <img className="" alt={event.name} src={event.poster_url} />
              <h2>{event.name}</h2>
              <p>{event.start_time}</p>
              <p>{event.end_time}</p>
              <p>{event.location}</p>
              {/* Later we'll add a spoiler here to show the description */}
            </figure>
          ))}
        </div>
      </section>
    )
  }
}

const mapStateToProps = (state, { match }) => {
  const groupId = match.params.groupId
  let loadingState = state.events.groupsLoaded[groupId] || 'notStarted'
  return {
    group: state.groups.list.find((group) => group.id == groupId),
    events: state.events.list.filter((event) => event.group_id == groupId),
    loadingState,
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    dispatchFetchGroup: (groupId) => dispatch(fetchGroup(groupId)),
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(GroupShowContainer)

Move the Fetch Requests to Action Creators & Utilize mapDispatchToProps

Move container component state to the redux store & Utilize mapStateToProps

Resources

Keep working in the woodshed until your skills catch up to your taste.
If you'd like to get in touch, reach out on LinkedIn.