Part 4
Repo | Starter Code | Finished Code |
---|---|---|
Rails API | 04_start | 04_end |
React Client | 04_start | 04_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 fromreact-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.
This will leave us room to expand the state in the store as our application grows.
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:
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.
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.
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:
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.
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:
- replacing our fetch in
componentDidMount
with a call dodispatchFetchGroup
- replacing references to
this.state
with references tothis.props
- 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)