Let’s continue studying the use of Redux with React. I have a little widget I built during React Lesson 2. It’s a text field combined with some buttons that let you commit your text (or you could calling “saving” the text) any time you want and then browse backward and forward through the commits. Mainly it was an exercise in working with React’s idiosyncratic component states. The source code for that project is in my GitHub repo https://github.com/xerocross/react-examples on the branch tiny-text-commit. For this post, I decided to refactor that project using Redux to handle the state. I’m just going to do it and then we will see afterward if it was a worthwhile thing to do.
I’m reading a Redux tutorial that begins here: https://redux.js.org/basics/basic-tutorial. From there, you can quickly get at least a beginner’s idea of actions, reducers, stores, and state. To add Redux to my project, I decided to create a new branch. The code for this new Redux-based version of the widget will be on the branch tiny-text-commit-redux. I’m building this new Redux logic into the project first before removing the previous machinery. I’ll compare as I go.
What they call “actions” look a lot like events to me. In this particular widget, I have these few different actions: COMMIT, GO_BACK, GO_FORWARD, and RESET. Those are all just primitives wrapped in an object like const COMMIT = { type: "COMMIT"}
. They don’t need to carry any information payload. I also have a NEW_TEXT action that comes with a string payload. For that one, I wrote an action creator function.
getNewTextAction (newText) {
return {
type: "NEW_TEXT",
text: newText
}
}
I defined all the actions and this action creator function in a redux-actions.js file.
Now we create a “reducer” function. Following the example in the tutorial linked above, the first thing I do is create an initial state and a function that returns the initial state if state is previously undefined, like this.
const initialState = {
text : "",
dirty: false,
next: null,
previous: null
}
export function textCommitApp (state, action) {
if (typeof state === 'undefined') {
return initialState
}
}
From this scaffolding, now we just have to handle what happens when you get some action and state IS defined. For now though, let’s switch over to actually instantiating this thing in our React component. My component is called TextCommitWidget now. I need to add our new dependencies, so I execute yarn add redux react-redux
in the shell. At the top of my component file, I add the imports.
import { createStore } from 'redux'
import { getNewTextAction, COMMIT, GO_BACK, GO_FORWARD, RESET } from "../redux-actions";
import { textCommitApp } from "../redux-reducers";
If we wanted to create some kind of global store for a much larger app, I’m not sure yet how we would do that, but check this out later: https://blog.logrocket.com/react-redux-connect-when-and-how-to-use-it-f2a1edab2013. Let’s neglect that for a moment and just create a local store that is only in scope for this component. Inside the component constructor, I add the line this.store = createStore(textCommitApp)
. Also, following the tutorial example, I see that it can print the store’s state to the console any time it changes by adding this.store.subscribe(() => console.log(this.store.getState()))
. This feels very similar to the RxJS Observable actually.
Now we begin building up the reducer function to handle our different actions. Here’s a start. It shows how we handle a NEW_TEXT action
export function textCommitApp (state, action) {
if (typeof state === 'undefined') {
return initialState
}
let newState = Object.assign({}, state)
switch (action.type) {
case "NEW_TEXT":
if (state.dirty) {
return {
text: action.text,
previous: state.previous,
next: null,
dirty : true
}
} else {
return {
text: action.text,
previous: state,
next: null,
dirty : true
}
}
default:
return newState;
}
}
Note that I had already written code that looked like this back before I was using Redux. Inside a handleNewText method, this was the previous code. I lifted the logic exactly, and almost exactly the same text.
if (this.state.dirty) {
this.setState((state, props)=>{
return {
text: text,
previous: state.previous,
next: null,
dirty : true
}
});
} else {
this.setState((state, props)=>{
return {
text: text,
previous: state,
next: null,
dirty : true
}
});
}
The only real difference is that in my reducer function above there is no mention of this.setState, nor anything React specific at all. The reducer function contains the logic of how the NEW_TEXT action alters the state in my widget, but we have removed this.setState, which is an implementation detail.
Now, inside the handleNewText method in the comonent, I add the line this.store.dispatch(getNewTextAction(text))
. This is what fires off the action and sends the text payload along for the Redux store to handle. Right now I’m also leaving the existing machinery in place so the widget still functions. But because I had all state changes in the Redux store logged to the console, now any time I type text into the widget I get a console log of the state object. For now, firing this action causes nothing else to happen.
We implement all the other actions in exactly the same way. The logic inside the reset handler was this code.
if (this.state.dirty) {
this.setState((state, props)=>{
let prev = state.previous;
return {
text: prev.text,
previous: prev.previous,
next: null,
dirty : false
}
});
}
When we move the logic into our reducer function, we have this very similar code.
if (state.dirty) {
let prev = state.previous;
return {
text: prev.text,
previous: prev.previous,
next: null,
dirty : false
}
} else {
return newState; // recall this is an unaltered clone of state
}
Back in the reset handler, we add this.store.dispatch(RESET)
. Before tearing down any of the previous machinery, we just do this for every action. Once that is done, we can see that this.store.getState() is mirroring the this.state object exactly. So we cross our fingers and comment out all of the previous code related to handling this.state. Instead, we add this to the constructor function.
this.store.subscribe(() => {
this.setState(()=> {
return this.store.getState()
})
});
With this done, we have shifted the burden of managing state entirely onto our Redux store. And it works. We use the dev server to play with the app. We run the unit tests. All are still successful.
Here are the benefits of this exercise as I see it. The logic of how state changes has been factored out and completely separated from the implementation details. That logic has nothing at all to do with React, and now that logic and React-specific crap are in two different files. Also, we only see that ugly setState function one time in this component now—just that one time, above, where we subscribed to the Redux store. For these reasons alone, I’m sold on Redux. It’s dead simple to use, and it’s beneficial even at small scale like this tiny widget.