DEV Community

John Raptis
John Raptis

Posted on • Updated on • Originally published at johnraptis.dev

Using Redux with Classes and Hooks

In this article we are going to see how to use Redux. The state management tool people love to hate.
I personally like it.

Prerequisites

  • Basic knowledge of React.
  • Have worked with Hooks.

Source code and demo down below

  • view source (example with class components is in a different branch named class_example)
  • view demo

What is Redux(Quickly)?

Redux is a state management tool that helps you control and update your applications state more efficiently.
Redux itself is a standalone library which means it's framework agnostic. You can use it with any framework but it's usually used with React.
Why should you use it? Passing props up and down can get nasty if you are dealing with larger applicatons. With Redux all your state lives in a single place, which encourage good React architecture.

Core Concepts

  • store: A central place that our state lives. It's created by calling a function.
  • reducer: Serves our state to the store and updates the state based on actions.
  • actions: Functions that are being dispatched(called) and tell the reducer what to do. They do that by sending action types.
  • Provider By wrapping our entire app with the Provider API we can access our store from anywhere in our app.

So the basic flow is:

Actions are being dispatched to the reducer. The reducer listens for the action type within a switch statement. If it doesn't find any match it will return the default(our state). The end result will be passed in a function named createStore to create our store.

Let's start and things will get clearer as we go.

Create your react app and install all of our dependencies.

create-react-app redux-tutorial
npm install redux react-redux

With Classes

We create a components folder with a component called SongList.js.
An actions folders and a reducers folder as well. In the actions folder we will add two additional files. One songActions.js which will handle all our actions and a types.js the we store our actions type names as constants.
In the reducers folder we will add a songReducers.js file that will handle all our reducers and an index file that will bring all our reducers together and combine them in one. In our case we have just one but we could have many.

Our file structure will look something like this.

src
  |
  actions
    |_ songActions.js
    |_ types.js
  components
    |_ SongList.js
  reducers
    |_ index.js
    |_ songReducers.js

Also add this css in index.css. Just to make things look a bit better.

/*
 index.css
*/
ul {    
    list-style: none;    
    max-width: 400px;    
    margin: 0 auto;    
    background: #ddd;    
    padding: 20px;    
    border-radius: 10px;
}

ul li {    
    padding: 5px;    
    margin-bottom: 10px;    
    background: #fff;    
    display: flex;    
    justify-content: space-between;
}

ul li button {    
    border: 2px solid #ddd;    
    background: #ddd;    
    cursor: pointer;   
    margin-left: 4px;
}

ul > form {    
    margin-top: 50px;
}

ul > form input[type="text"] {    
    height: 24px;    
    padding: 4px;    
    border: none;    
    font-size: .9rem;
}

ul > form input[type="submit"] {   
    padding: 8px;    
    border: none;    
    background: #333;    
    color: #ddd;    
    font-size: .8rem;
}

First in our App.js we import our Provider which will wrap our entire app,the createStore function that creates our store and allReducers that is the collection of one or many reducers.

After importing our SongList.js component we store our apps entire state in a store variable.

//
//App.js
//
import React from 'react'
import './App.css'

import { Provider } from 'react-redux'
import { createStore } from 'redux'
import allReducers from './reducers'

import SongList from './components/SongList'

let store = createStore(allReducers);

Then we wrap everything.

. . .
function App() {
  return (
    <Provider store={store}>
      <div className="App">
        <h1>Songs(with the help of Redux)</h1>
        <SongList />
      </div>
    </Provider>
  );
}
. . .

In our songReducers.js file we set our initial state and pass it in our reducer function. In the switch statement we are going to listen for an action. If none is provided or called we are going to set it to return the state by default.

//
// songReducers.js
//
const initialState = {
    songs: [
        {title: 'I love redux'},
        {title: 'The redux song'},
        {title: 'Run to the redux hill'}
    ]
}

export default function(state = initialState, action) {
    switch(action.type) {
        default:
            return state;
    }
}

In our reducers/index.js we import all our applications reducers (in our case just one) and pass them to a function named combineReducer. And it does what the name implies. Combines all of our reducers in one and that is what is passed in the createStore function in App.js

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

const allReducers = combineReducers({
    songs: songReducers
});


export default allReducers;

Now the fun part. Let's bring and consume our state in the SongList.js component. There are a lot to cover here so bear with me.

We import the connect function that will wrap our SongList.js component. With connect we will actually be able to access our state as props.
connect takes four optional parameters, but in our case we will use the first two.
mapStateToProps and mapDispatchToProps. If we use only one of two the one we don't use should be passed as null.

mapStateToProps provide us with the store. Every time the state changes this function will be called

It takes two parameters. state and ownProps.
With state the function is called when the state changes.
With state and ownProps the function is called both when the state changes and when the current component receives props. In our case we just pass state and set songs with the state.songs that was created by our store.

//
// SongList.js
//
. . .
const mapStateToProps = (state) => ({
  songs: state.songs
});
. . .

mapDispatchToProps will provide us with the actions we need to use in our component so we can dispatch them and change our state.

It may be a function or an object. In our case it will be an object of the actions we imported from the songActions.js.

It will look something like this.

//
// SongList.js
//
import React from 'react'
import { connect } from 'react-redux'
import { actionOne, actionTwo } from '../actions/songActions'

. . .

const mapDispatchToProps = {
    actionOne,
    actionTwo,
}

export default connect(mapStateToProps, mapDispatchToProps)(SongList);

Or we can destructure.

export default connect(mapStateToProps, { actionOne, actionTwo })(SongList);

Since we don't have any actions yet we pass null.
Later on we will pass all the actions we need.

const mapStateToProps = state => ({
  songs: state.songs
});

export default connect(mapStateToProps, null)(SongList);

Now we can access the songs we defined in mapStateToProps as props in our component.
We destructure it in our render function.

//
// SongList.js
//
import React from 'react'
import { connect } from "react-redux"

class SongList extends React.Component {

    render() {
        const { songs } = this.props.songs;
        return (
            <ul>
            {songs.map((song, i) => {
                return (
                    <li key={song.title}>
                    {song.title}
                    </li>
                )
            })}
            </ul>
        );
    }
}

const mapStateToProps = state => ({
  songs: state.songs
});

export default connect(mapStateToProps, null)(SongList);

Now let's see how can we add new songs, delete songs and update songs as well.

In the code below we add a form. when input changes we call the onChange function, that sets our local state. On the onSubmit function we dispatch an action with our newSong as a parameter.

Note: that we start to populate our connect function with the actions we are using.

//
// SongList.js
//
. . .
import { addSong } from '../actions/songActions'

. . .

constructor(props) {
    super(props);
    this.state = {
      newSong: '',
    };

    this.onChange = this.onChange.bind(this);
    this.onSubmit = this.onSubmit.bind(this);
    this.remove = this.remove.bind(this);
  }

    onSubmit(e) {
        e.preventDefault();

        const addedSong = {
            title: this.state.newSong
        }

        this.props.addSong(addedSong);
        this.setState({ newSong: '' });
    }    

    onChange(e) {
       this.setState({ [e.target.name]: e.target.value });
    }

    render() {
        const { songs } = this.props.songs;
        return (
            <ul>
            {songs.map((song , i) => {
                return (
                    <li key={song.title}>
                    {song.title}
                    </li>
                )
            })}
            <form onSubmit={this.onSubmit}>
                <input type="text" name="newSong" onChange={this.onChange} />
                <input type="submit" value="Add Song" />
            </form>
            </ul>
        );
    }
}

const mapStateToProps = state => ({
  songs: state.songs
});

export default connect(mapStateToProps, { addSong })(SongList);

In songActions.js we create the addSong function and pass the newSong as payload. Payload is data we pass with the action, second parameter in the switch statement in songReducers.js. We access it as action.payload.

//
// songActions.js
//
import { ADD_SONG } from './types'

export const addSong = (song) => {
    return {
        type: ADD_SONG,
        payload: song
    }
}

Note: It is considered best practice to store the action types as constants in a file named types.js in the actions folder.

//
// actions/types.js
//
export const ADD_SONG = 'ADD_SONG';

Do this with every additional action typey you add.

Now the songReducers.js will look like this. The action.payload is the song parameter we passed in our addSong function.

//
// songReducers.js
//
. . .
export default function(state = initialState, action) {
  switch(action.type) {
    case ADD_SONG:
      return {
        songs: [action.payload, ...state.songs]    
      }
    default:
      return state;
    }
}
. . .

To remove a song we follow the same process.

We create a button. When clicking we call the remove function with the index of the song as a parameter. Again we dispatch the removeSong action.

//
// SongList.js
//
. . .
import { addSong, removeSong } from '../actions/songActions'

. . .

  remove(i) {
        this.props.removeSong(i);
    }

    render() {
        const { songs } = this.props.songs;
        return (
            <ul>
            {songs.map((song , i) => {
                return (
                    <li key={song.title}>
                    {song.title}
                    <button onClick={() => this.remove(i)}>Delete</button>
                    </li>
                )
            })}
            <form onSubmit={this.onSubmit}>
                <input type="text" name="newSong" onChange={this.onChange} />
                <input type="submit" value="Add Song" />
            </form>
            </ul>
        );
    }
}

const mapStateToProps = state => ({
  songs: state.songs
});

export default connect(mapStateToProps, { addSong, removeSong })(SongList);

Lastly to update a song we must change a few things. First we will modify our initialState by adding editing: false in each of our song object. This will control which song is being edited.

//
// songReducers.js
//
. . .
const initialState = {
    songs: [
        {title: 'I love redux', editing: false},
        {title: 'The redux song', editing: false},
        {title: 'Run to the redux hill', editing: false}
    ]
}
. . .

In our songList.js component depending if a songs editing state is true or false, we will render a different li.

//
// SongList.js
//
. . .

render() {
        const { songs } = this.props.songs;
        return (
            <ul>
            {songs.map((song , i) => {
                return (
                    <Fragment key={song.title}>
                    {(!song.editing) ? (
                    <li>
                    {song.title}
                        <span>
                          <button onClick={() => this.remove(i)}>Delete</button>
                          <button onClick={() => this.edit(i, song.title)}>Edit</button>
                        </span>
                    </li>
                        ) : (
                    <li>
                         <form>
                            <input
                            type="text"
                            name="currentVal"
                            value={this.state.currentVal}
                            onChange={this.updatedVal}
                            />
                        </form>
                         <span>
                             <button onClick={() => this.cancel(i)}>Cancel</button>
                             <button onClick={() => this.update(i)}>Update</button>
                        </span>
                    </li>
                        )}
                    </Fragment>
                )
            })}
            <form onSubmit={this.onSubmit}>
                <input
                type="text"
                name="newSong"
                onChange={this.onChange}
                />
                <input type="submit" value="Add Song" />
            </form>
            </ul>
        );
    }

 . . .

With our new adjustments the whole thing looks like this.

//
// SongList.js
//
import React, { Fragment } from 'react'
import { connect } from 'react-redux'
import {
    addSong,
    removeSong,
    editSong,
    updateSong,
    cancelEdit
} from '../actions/songActions'


class SongList extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      newSong: '',
      currentVal: ''
    };

    this.onChange = this.onChange.bind(this);
    this.onSubmit = this.onSubmit.bind(this);
    this.remove = this.remove.bind(this);
    this.edit = this.edit.bind(this);
    this.update = this.update.bind(this);
    this.cancel = this.cancel.bind(this);
    this.updatedVal = this.updatedVal.bind(this);
  }

    onSubmit(e) {
        e.preventDefault();

        const addedSong = {
            title: this.state.newSong
        }

        this.props.addSong(addedSong);
        this.setState({ newSong: '' });
    }    

    onChange(e) {
        this.setState({ [e.target.name]: e.target.value });
    }

    updatedVal(e) {
        this.setState({ [e.target.name]: e.target.value });
    }

    remove(i) {
        this.props.removeSong(i);
    }

    edit(i, title) {
        this.props.editSong(i);
        this.setState({ currentVal: title })
    }

    update(i) {
        this.props.updateSong(this.state.currentVal, i);
        this.setState({ currentVal: '' })
    }

     cancel(i) {
        this.props.cancelEdit(i);
    }

    render() {
        const { songs } = this.props.songs;
        return (
            <ul>
            {songs.map((song , i) => {
                return (
                    <Fragment key={song.title}>
                    {(!song.editing) ? (
                    <li>
                    {song.title}
                        <span>
                            <button onClick={() => this.remove(i)}>Delete</button>
                            <button onClick={() => this.edit(i, song.title)}>Edit</button>
                        </span>
                    </li>
                        ) : (
                    <li>
                         <form>
                            <input
                            type="text"
                            name="currentVal"
                            value={this.state.currentVal}
                            onChange={this.updatedVal}
                            />
                        </form>
                         <span>
                             <button onClick={() => this.cancel(i)}>Cancel</button>
                             <button onClick={() => this.update(i)}>Update</button>
                        </span>
                    </li>
                        )}
                    </Fragment>
                )
            })}
            <form onSubmit={this.onSubmit}>
                <input
                type="text"
                name="newSong"
                onChange={this.onChange}
                />
                <input type="submit" value="Add Song" />
            </form>
            </ul>
        );
    }
}

const mapStateToProps = state => ({
  songs: state.songs
});

export default connect(mapStateToProps, {
    addSong,
    removeSong,
    editSong,
    updateSong,
    cancelEdit
})(SongList);

The songActions.js looks like this.

//
// songActions.js 
//
import {
    ADD_SONG,
    DELETE_SONG,
    EDIT_SONG,
    UPDATE_SONG,
    CANCEL_EDIT
} from './types'

export const addSong = (song) => {
    return {
        type: ADD_SONG,
        payload: song
    }
}

export const removeSong = (index) => {
    return {
        type: DELETE_SONG,
        payload: index
    }
}

export const editSong = (index) => {
    return {
        type: EDIT_SONG,
        payload: index
    }
}

export const updateSong = (title, index) => {
    return {
        type: UPDATE_SONG,
        title,
        index
    }
}

export const cancelEdit = (index) => {
    return {
        type: CANCEL_EDIT,
        index
    }
}

And the songReducer.js looks like this.

//
// songReducers.js
//
import {
    ADD_SONG,
    DELETE_SONG,
    EDIT_SONG,
    UPDATE_SONG,
    CANCEL_EDIT
} from '../actions/types'

const initialState = {
    songs: [
        {title: 'I love redux', editing: false},
        {title: 'The redux song', editing: false},
        {title: 'Run to the redux hill', editing: false}
    ]
}

export default function(state = initialState, action) {
    switch(action.type) {
        case ADD_SONG:
            return {
                songs: [action.payload, ...state.songs]    
            }
        case DELETE_SONG:
            return {
                songs: state.songs.filter((s, i) => i !== action.payload)
            }
        case EDIT_SONG:
            return {
            songs: state.songs.map((song, i) =>
            i === action.payload
            ? { ...song, editing: true }
            : { ...song, editing: false }
                )
            }
        case UPDATE_SONG:
            return {
            songs: state.songs.map((song, i) =>
            i === action.index
            ? { ...song, title: action.title, editing: false}
            : song
        )
            }
        case CANCEL_EDIT:
            return {
        songs: state.songs.map((song, i) =>
            i === action.index ? { ...song, editing: false } : song
        )
            }
        default:
            return state;
    }
}

With Hooks

Using Redux with Hooks is way better. It's has fewer boilerplate and I think is easier to work with.
Although it adds a layer of abstraction, if you know the Class way of doing it first, things will stay pretty lean and self-explanatory.

Our songActions.js and songReducers.js will look exactly the same. The only difference is in our SongList.js component.

Instead of connect we are going to use the useSelector hook to access parts of the state directly, and useDispatch to dispatch actions.

useSelector is somewhat equivalent to mapStateToProps and useDispatch is somewhat equivalent to mapDispatchToProps. They have some differences though which you can check the documentation for details.

//
// SongList.js
//
import React, { Fragment, useState } from 'react'
import { useDispatch, useSelector } from "react-redux"
import {
    addSong,
    removeSong,
    editSong,
    updateSong,
    cancelEdit
     } from '../actions/songActions'

const SongList = () => {
    const dispatch = useDispatch()
    const [newSong, setNewSong] = useState();
    const [currentVal, setCurrentVal] = useState();
    const { songs } = useSelector(state => state.songs)

    const addNewSong = (e) => {
        e.preventDefault();

        const addedSong = {
            title: newSong
        }

        if(addedSong.title) {
            dispatch(addSong(addedSong))
            setNewSong('')
        }
    }    

    const remove = (i) => {
        dispatch(removeSong(i))
    }

    const update = (i) => {
        dispatch(updateSong(currentVal, i))
        setCurrentVal('')
    }

    const edit = (i, title) => {
        dispatch(editSong(i))
        setCurrentVal(title)
    }

    const cancel = (i) => {
        dispatch(cancelEdit(i))
    }

    return (
        <ul>
        {songs.map((song , i) => {
            return (
                <Fragment key={song.title}>
                {(!song.editing) ? (
                <li>
                {song.title}
                    <span>
                        <button onClick={() => remove(i)}>Delete</button>
                        <button onClick={() => edit(i, song.title)}>Edit</button>
                    </span>
                </li>
                    ) : (
                <li>
                    <form>
                        <input type="text" value={currentVal} onChange={e => setCurrentVal(e.target.value)} />
                    </form>
                    <span>
                        <button onClick={() => cancel(i)}>Cancel</button>
                        <button onClick={() => update(i)}>Update</button>
                    </span>
                </li>
                    )}
                </Fragment>
            )
        })}
            <form onSubmit={addNewSong}>
                <input type="text" onChange={e => setNewSong(e.target.value)} />
                <input type="submit" value="Add Song" />
            </form>
        </ul>
    )
}

export default SongList

Conclusion

That is pretty much it. Redux can get more complicated but the core concepts are the ones mentioned.

Top comments (2)

Collapse
 
markerikson profile image
Mark Erikson

Hi, I'm a Redux maintainer. Got a couple quick bits of feedback for you.

First, I'd specifically encourage you to try out our new official Redux Toolkit package. It includes utilities to simplify several common Redux use cases, including store setup, defining reducers, immutable update logic, and even creating entire "slices" of state at once, without writing any action creators or action types by hand:

redux-toolkit.js.org

Second, there's actually at least one bug in your code examples, and RTK actually would have prevented it:

        case UPDATE_SONG:
            return {
                songs: state.songs.filter((song, i) => {
                    if(i === action.index) {
                         song.title = action.title
                        song.editing = false
                    }
                    return song
                })
            }

This reducer logic is mutating the existing song objects. It does create a new songs array, but the song object is being mutated. To be a correct immutable update, you'd need to copy song, like return {...song, title: action.title, editing: false}.

RTK includes a mutation checking middleware by default when you use its configureStore() function, which would have thrown an error after that mutation to let you know it happened.

But, with RTK's use of Immer in its createReducer and createSlice functions, that could have simply been:

updateSong(state, action) {
    const song = state.songs[action.payload];
    // "mutates" the song, but it's safe if we do that with Immer
    song.title = action.title; 
}

In addition, we would recommend using a "feature folder" or a "ducks" folder structure, as shown in the RTK "Advanced Tutorial" page, rather than a "folder by type" approach.

Hope that helps!

Collapse
 
john2220 profile image
John Raptis

Wow!!! Thanks for the feedback. Very interesting suggestions. I will definitely check out the RTK.

Cheers