Today you’ll be building a to-do list application with React and local storage. Instead of using Context to manage and update your application’s state, you’ll set up a Redux store and interact with it using the store’s getState
, dispatch
, and subscribe
methods.
This project will also give you a better understanding of how to share and update “global” data across a React application by using Redux. You’ll use Redux to dispatch action POJOs through a reducer function, and have your component access an updated version of the Redux store’s state.
In this project, you will:
createStore
method from the Redux librarydebugger
to investigate the state from within a componentBegin by cloning the starter project from https://github.com/appacademy-starters/react-redux-todo-list-starter.
Take a moment to examine the project’s file tree below. In the next few phases, you’ll follow the TODO
notes in each file to implement Redux into your React project.
├── package-lock.json
├── package.json
├── public
│ └── index.html
└── src
├── App.js
├── actions
│ └── taskActions.js
├── components
│ ├── Task.js
│ ├── TodoForm.js
│ └── TodoList.js
├── index.js
├── localStorage.js
├── reducers
│ └── tasksReducer.js
└── store.js
Let’s start by setting up some functions in the localStorage.js
file to save and load the Redux store’s state with local storage!
In the loadState
function, you’ll want to access the stored tasks state from local storage by using the localStorage.getItem
method. If there is no state found, return undefined
. However, if the state was found, parse the state from JSON into JavaScript and return the parsed state. If any errors were caught, log the errors with a console.warn
statement and have the function return undefined
.
In the saveState
function, you’ll want to parse the state
input from JavaScript into a JSON string. When you call the saveState
function, you’ll invoke the function with the Redux store’s state accessed with the store.getState
method. After parsing the state from JavaScript into a JSON string, set the string into local storage. Lastly, you’ll want to catch any errors with a console.warn
statement.
Now that you’ve set up some functions to handle accessing and storing the data with local storage, you’ll want to use those functions in the store.js
file. In this file, you’ll use Redux’s createStore
function to set up your application’s Redux store
. As a reminder, the createStore
function takes in a reducer as its first argument, and an optional preloaded state, also referred to as initial state, as its second argument.
Use the loadState
function you just defined to access the preloadedState
. Now you’ll invoke the createStore
function with the tasksReducer
and the preloadedState
to generate the application’s Redux store.
You’ll want your application to update local storage and log the state whenever there an update to the store - this means you’ll want your application to listen for changes to the store with the store.subscribe
method and then update local storage with the saveState
function and console.log
the state upon any change.
Now that you have your application’s Redux store set up, it’s time to define some action creator functions and reducers! You’ll define action creator functions in the taskActions.js
file and set up corresponding case statements for each action type in the tasksReducer.js
file.
As a reminder, it is best practice to use constants for action types, instead of string literals, to ensure that errors will be thrown for typos. Start by defining constants for your action types: CREATE_TASK
and DELETE_TASK
.
Once you have the constants set up, it’s time to define an action creator function for each action type! Start by thinking of what payload information you want your action POJOs to pass into the reducer function.
Define a createTask
action creator function that returns actions of type CREATE_TASK
. You’ll want type
, taskId
, and taskMessage
payload keys for each CREATE_TASK
action POJO. Have the action creator function take in a taskMessage
and auto-generate the taskId
. You can set the taskId
to a time-string that is set when the action creator function is invoked. Generate a new Date
object and get its time-string with new Date().getTime()
. Set the time-string to the taskId
payload key and the taskMessage
input to the taskMessage
payload key.
Now you’ll want to define the deleteTask
action creator function to return actions of type DELETE_TASK
. You’ll want the action creator function to take in a taskId
. Each DELETE_TASK
action POJO should have a type
property and a taskId
payload key.
The next step is to finish implementing the tasksReducer
! Begin by freezing the state
with Object.freeze(state);
so that you won’t accidentally mutate the state. As a reminder, Redux follows the immutable state pattern, meaning that a reducer function should never directly mutate state. After freezing the state, import CREATE_TASK
and DELETE_TASK
string literal constants and set up a switch statement to evaluate a case statement based on each action.type
.
In the CREATE_TASK
case, you’ll want to make a copy of the state, structure a newTask
POJO, and add the newTask
into the copy of the state before returning the copy. Define a nextState
variable and use spread syntax (...
) to make a copy of the state ({ ...state }
). Next, you’ll want to structure the newTask
POJO to have an id
property set to the action’s taskId
payload and a message
property set to the action’s taskMessage
payload.
Once you have finished structuring the newTask
POJO, key into the nextState
with the new task ID and set the value of nextState[newTask.id]
to the newTask
. Alternatively, you could use the taskId
payload and set the value of nextState[action.taskId]
to the newTask
(this will also accomplish what we want, which is to set up a nextState
with keys that are task IDs and values that are task POJOs). At the end of the CREATE_TASK
case statement, return the updated nextState
.
In the DELETE_TASK
case, you’ll also want to make a copy of the state ({...state }
). Set the copy of the state to a stateWithDeletion
variable. Since your DELETE_TASK
actions have a taskId
payload, you can use JavaScript’s delete operator to delete a specific key-value pair from the stateWithDeletion
object, based on the taskId
payload:
The last thing left in your DELETE_TASK
statement is to return the updated stateWithDeletion
! If you compare your initial definition of the nextState
and stateWithDeletion
variables, you’ll see that they are both copies of the state
made with spread syntax. Move the nextState
variable outside of the switch
statement so that both case
statements can reference and update the nextState
, instead of the DELETE_TASK
case statement creating a new copy of the state and updating it.
Now you can test whether you can actually create a task by using the store.dispatch
method to dispatch the CREATE_TASK
action. As a reminder, dispatching the action will “send” it through the reducer to determine what operation to perform based on the action’s type
property. Take a moment to go into your index.js
file and import your application’s Redux store
and action creator functions:
Now that you’ve had the store and actions imported into the file, you can set them as properties to the window
object, so that you can access the store
and actions from the developer tools console.
At this point, your index.js
file should look something like this:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { store } from './store';
import { createTask, deleteTask } from './actions/taskActions';
window.store = store;
window.createTask = createTask;
window.deleteTask = deleteTask;
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Open up your browser’s DevTools console and type window.store
. Now you should see the store
object and its methods: {dispatch: ƒ, subscribe: ƒ, getState: ƒ, replaceReducer: ƒ, Symbol(observable): ƒ}
,
Now type window.store.getState()
. You should see an empty object - this is the default state (state = {}
) that you set up in the tasksReducer
.
Since you can access your application’s state from the DevTools console, that means you can also dispatch actions by invoking the window.store.dispatch
method with an action:
You just dispatched a CREATE_TASK
action! You’ll see that your updated state was logged - this is because of the console.log
statement in the store.subscribe
invocation in your index.js
file (as you might remember, the store.subscribe
method listens for any updates to the store, i.e. dispatch calls). Dispatch another CREATE_TASK
action:
Now if you type window.store.getState()
again, you’ll see that the state return from the store.getState
method is the same plain old JavaScript object as the state that was logged within the store.subscribe
invocation.
Now let’s place some debugger
statements in the tasksReducer
and createTask
action creator function! Remember to make sure the debugger
statement in your tasksReducer
is inside a case statement. If the debugger
is between the switch statement and a case statement, you will never hit that breakpoint!
const tasksReducer = (state = {}, action) => {
Object.freeze(state);
switch (action.type) {
case CREATE_TASK:
debugger;
// CODE SHORTENED FOR BREVITY
export const createTask = (taskMessage) => {
debugger;
return ({
type: CREATE_TASK,
taskId: new Date().getTime(),
taskMessage,
});
};
Now dispatch another CREATE_TASK
action to hit the two debugger
statements you just set:
Notice how you are now in the Sources
tab of your DevTools looking at the taskActions.js
file in your project. You can view the value of the taskMessage
argument by hovering over the variable or looking at the local scope variables in the DevTools’ right window.
If you click the blue play button to continue to the next debugger
statement, you’ll land in your tasksReducer.js
file and be able to hover over the state
to view the value of the Redux store’s previous state before the dispatching of the CREATE_TASK
action.
Now if you click the blue play button to continue, you’ll exit out of debug mode and your updated state will be logged in the console.
Congratulations! You just used a debugger
to follow the Redux flow of dispatching a CREATE_TASK
action! Comment out your debugger
statements for now. In the next phase, you’ll work on dispatching actions through a user interface.
Now it’s time to set up a user interface that allows for intuitive dispatching of actions. In the TodoForm
component, you’ll set up a button that invokes the createTask
action creator function with the inputValue
state to dispatch a CREATE_TASK
action based on the form input! For each Task
component, you’ll set up a button to dispatch a DELETE_TASK
action for that task.
In the TodoForm.js
file, import your application’s Redux store
instance and the createTask
action creator function. Now you’ll want to finish the handleSubmit
method so that it dispatches a CREATE_TASK
action. Invoke the createTask
action creator function with the inputValue
state and the store.dispatch
method with the invoked action creator function.
Take a moment to test out the dispatch call generated by your form submission. Type a task in the input field - when you submit, you should see an updated state logged in the DevTools console with your new task!
In the TodoList.js
file, import the application’s Redux store
instance and the deleteTask
action creator function. Now you’ll set up the component’s componentDidMount
and componentDidUnmount
life-cycle methods.
In the componentDidMount
method, use the store’s subscribe
method to force a component to update whenever the state changes:
You want to name the subscription as this.unsubscribe
, so that you can unsubscribe upon the unmounting of a component. When the componentDidMount
life-cycle method is invoked upon the mounting of a component, it will invoke the store.subscribe
method to force the component to update whenever the store’s state changes. It will also set a this.unsubscribe
variable to the TodoList
class, so that this.unsubscribe
is accessible from other parts of the component’s code.
In the componentDidUnmount
method, you’ll want to check if the component has mounted by checking if this.unsubscribe
has been defined. Whenever a component mounts, the this.unsubscribe
variable set in the componentDidMount
method will become initialize. If this.unsubscribe
is undefined, that means that the component has not invoked the componentDidMount
method and has therefore not been mounted yet. If this.unsubscribe
is defined, you’ll want to invoke this.unsubscribe
to have the component unsubscribe from changes once component unmounts:
In the deleteTask
method, you’ll want to wrap the invocation of the deleteTask
action creator function with the store.dispatch
method. The deleteTask
action creator function will be invoked based on the this.deleteTask
method’s id
input. Later in this phase, you’ll pass the TodoList
component’s this.deleteTask
method as a deleteTask
prop into each Task
component. Then, whenever the deleteTask
prop is invoked from within a Task
component, it can simply be invoked with a task ID to dispatch a DELETE_TASK
action without needing to import the store
into each Task
component to invoke store.dispatch
In the component’s render
method, access the tasks stored in the Redux store’s state by invoking the store.getState
method and saving its return value to a tasksState
variable. Now that you can use a debugger
statement to view the state and check out what data you are working with!
If there are no tasks stored in state, you’ll want to have the TodoList
component return null
. Otherwise if there are tasks stored in state, render a Task
component for each of the tasks. For each Task
component, you’ll want to use the task’s ID as the key
and pass two props: the task
object and the this.deleteTask
method as a deleteTask
prop.
Have the Task
function component destructure and take in the deleteTask
method and task
object props. Invoke the deleteTask
function passed as a prop in the Task
component’s handleClick
method and replace the Hi, I'm a task in your to-do list!
placeholder text with the task.message
.
As a reminder, the deleteTask
action creator function was already wrapped with a store.dispatch
call in the TodoList
component - this is why the handleClick
function in the Task
component does not include a store.dispatch
invocation. The TodoList
component passed the wrapped function as a prop named deleteTask
to each Task
component. The deleteTask
function invoked in the Task
component’s handleClick
function is the TodoList
component’s deleteTask
method, not the deleteTask
action creator function.
In this phase, you’ll implement a full Redux cycle without the guidance of TODO
notes or specific, written instructions. Remember, the debugger
statement is your friend! If you get stuck, think of where you can place debugger
statements to gain more context about your code. As a general guideline, feel free to follow the steps below:
RESET_TASK_LIST
actionRESET_TASK_LIST
action typeRESET_TASK_LIST
actionCongratulations! You have just created an application that uses Redux to manage the application’s information. Give yourself a pat on the back! As a reminder, the Redux library is a highly conceptual library to pick up, and when learning anything new practice always makes perfect! If the implementation of Redux feels confusing, always feel free to step back and use a debugger
statement to follow the Redux flow: an action is generated, then the action is dispatched to go through a reducer, and then the store is updated.