c
Communicating with a server in a redux application
Let's expand our application so that the tasks are stored in the backend. We'll use json-server, familiar from part 2.
The initial state of the database is stored in the file db.json, which is placed in the root of the project:
{
"tasks": [
{
"content": "learn more about how the app state is in redux store",
"important": true,
"id": 1
},
{
"content": "understand more fully how state changes are made with actions",
"important": false,
"id": 2
}
]
}
Let's install both axios
and json-server
to the project:
npm i -D json-server
npm i axios
Then add the following line to the scripts
part of our package.json
"scripts": {
// ...
"server": "json-server -p3001 --watch db.json"
}
We can now launch json-server with the command npm run server
.
Setting up the application to work with a server
Next, in a new file services/tasks.js,
we'll create a method getAll
that uses axios
to fetch data from the backend
import axios from 'axios'
const baseUrl = 'http://localhost:3001/tasks'
const getAll = async () => {
const response = await axios.get(baseUrl)
return response.data
}
export default { getAll }
We'll change the initialization of the state in taskReducer.js, so that by default there are no tasks:
const taskSlice = createSlice({
name: 'tasks',
initialState: [], // ...
})
Let's also add a new action appendTask
for adding a task object:
const taskSlice = createSlice({
name: 'tasks',
initialState: [],
reducers: {
createTask(state, action) {
const content = action.payload
state.push({
content,
important: false,
id: generateId(),
})
},
toggleImportanceOf(state, action) {
const id = action.payload
const taskToChange = state.find(t => t.id === id)
const changedTask = {
...taskToChange,
important: !taskToChange.important
}
return state.map(task =>
task.id !== id ? task : changedTask
)
},
appendTask(state, action) { state.push(action.payload) } },
})
export const { createTask, toggleImportanceOf, appendTask } = taskSlice.actions
export default taskSlice.reducer
A quick way to *initialize the tasks' state based on the server's data* is to *fetch the tasks* in main.jsx
and dispatch
an action using the appendTask
action creator for each individual task object:
// ...
import taskService from './services/tasks'import taskReducer, { appendTask } from './reducers/taskReducer'
const store = configureStore({
reducer: {
tasks: taskReducer,
filter: filterReducer,
}
})
taskService.getAll().then(tasks => tasks.forEach(task => { store.dispatch(appendTask(task)) }))
// ...
Further refactoring of our backend
Dispatching multiple appendTasks
seems a bit impractical.
Let's add an action creator setTasks
which can be used to directly replace the tasks array.
We'll get the action creator from the createSlice
function by implementing the setTasks
action:
// ...
const taskSlice = createSlice({
name: 'tasks',
initialState: [],
reducers: {
createTask(state, action) {
const content = action.payload
state.push({
content,
important: false,
id: generateId(),
})
},
toggleImportanceOf(state, action) {
const id = action.payload
const taskToChange = state.find(t => t.id === id)
const changedTask = {
...taskToChange,
important: !taskToChange.important
}
return state.map(task =>
task.id !== id ? task : changedTask
)
},
appendTask(state, action) {
state.push(action.payload)
},
setTasks(state, action) { return action.payload } },
})
export const { createTask, toggleImportanceOf, appendTask, setTasks } = taskSlice.actions
export default taskSlice.reducer
Now, the code in main.jsx looks a lot better:
// ...
import taskService from './services/tasks'
import taskReducer, { setTasks } from './reducers/taskReducer'
const store = configureStore({
reducer: {
tasks: taskReducer,
filter: filterReducer,
}
})
taskService.getAll().then(tasks =>
store.dispatch(setTasks(tasks)))
Pertinent: why didn't we use
await
in place of promises and event handlers (registered tothen
methods)?
await
only works insideasync
functions, andtaskService.getAll
in main.jsx is not inside a function, so we'll abstain from usingasync
here.
Let's refactor the task initialization into the App
component,
and, as usual, when fetching data from a server, we'll use the effect hook.
import NewTask from './components/NewTask'
import Tasks from './components/Tasks'
import VisibilityFilter from './components/VisibilityFilter'
import { useEffect } from 'react'import { useDispatch } from 'react-redux'import taskService from './services/tasks'import { setTasks } from './reducers/taskReducer'
const App = () => {
const dispatch = useDispatch() useEffect(() => { taskService .getAll().then(tasks => dispatch(setTasks(tasks))) }, [])
return (
<div>
<NewTask />
<VisibilityFilter />
<Tasks />
</div>
)
}
export default App
Sending data to the backend
We can do the same thing when it comes to creating a new task. Let's expand the code communicating with the server in services/tasks.js:
const baseUrl = 'http://localhost:3001/tasks'
const getAll = async () => {
const response = await axios.get(baseUrl)
return response.data
}
const createNew = async (content) => { const object = { content, important: false } const response = await axios.post(baseUrl, object) return response.data}
export default {
getAll,
createNew,}
The method addTask
in components/NewTask.js changes slightly:
import { useDispatch } from 'react-redux'
import { createTask } from '../reducers/taskReducer'
import taskService from '../services/tasks'
const NewTask = (props) => {
const dispatch = useDispatch()
const addTask = async (event) => { event.preventDefault()
const content = event.target.task.value
event.target.task.value = ''
const newTask = await taskService.createNew(content) dispatch(createTask(newTask)) }
return (
<form onSubmit={addTask}>
<input name="task" />
<button type="submit">add</button>
</form>
)
}
export default NewTask
Because the backend generates ids for the tasks, we'll change the action creator createTask
and remove generateId
in the file taskReducer.js accordingly:
const taskSlice = createSlice({
name: 'tasks',
initialState: [],
reducers: {
createTask(state, action) {
state.push(action.payload) },
// ..
},
})
Changing the importance of tasks could be implemented using the same principle, by making an asynchronous method call to the server and then dispatching an appropriate action.
The current state of the code for the application can be found on GitHub in the branch part6-4.
Asynchronous actions and Redux thunk
Our approach is pretty good, but we can improve the separation between the components and the server communication.
It would be better if the communication could be abstracted away from the components
so that they don't have to do anything else but call the appropriate action creator.
As an example, App
would initialize the state of the application as follows:
const App = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(initializeTasks()))
}, [dispatch])
// ...
}
and NewTask
would create a new task as follows:
const NewTask = () => {
const dispatch = useDispatch()
const addTask = async (event) => {
event.preventDefault()
const content = event.target.task.value
event.target.task.value = ''
dispatch(createTask(content))
}
// ...
}
In this implementation, both components would dispatch
an action
without the need to know about the communication between the server, which happens behind the scenes.
These kinds of async actions can be implemented using the Redux Thunk library.
The use of the library doesn't need any additional configuration/installation when we use configureStore
.
With Redux Thunk it is possible to implement action creators which return a function instead of an object.
The function receives Redux store's dispatch
and getState
methods as parameters.
This allows implementations of asynchronous action creators,
which:
- wait for the completion of a specific asynchronous operation
- dispatch an action, which changes the store's state.
We can define an action creator initializeTasks
which initializes the tasks based on the data received from the server:
// ...
import taskService from '../services/tasks'
const taskSlice = createSlice(/* ... */)
export const { createTask, toggleImportanceOf, appendTask, setTasks } = taskSlice.actions
export const initializeTasks = () => { return async dispatch => { const tasks = await taskService.getAll() dispatch(setTasks(tasks)) }}
export default taskSlice.reducer
In the inner function, meaning the asynchronous action, the operation first fetches all the tasks from the server (getAll
)
and then dispatches the setTasks
action, which adds them to the store.
The component App
can now be defined as follows:
// ...
import { initializeTasks } from './reducers/taskReducer'
const App = () => {
const dispatch = useDispatch()
useEffect(() => { dispatch(initializeTasks()) }, [dispatch])
return (
<div>
<NewTask />
<VisibilityFilter />
<Tasks />
</div>
)
}
The solution is elegant. The initialization logic for the tasks has been completely separated from the React component.
Refactoring to separate task creation
Next, let's replace the createTask
action creator created by the createSlice
function with an asynchronous action creator:
// ...
import taskService from '../services/tasks'
const taskSlice = createSlice({
name: 'tasks',
initialState: [],
reducers: {
// createTask definition removed from here!
toggleImportanceOf(state, action) {
const id = action.payload
const taskToChange = state.find(t => t.id === id)
const changedTask = {
...taskToChange,
important: !taskToChange.important
}
return state.map(task =>
task.id !== id ? task : changedTask
)
},
appendTask(state, action) {
state.push(action.payload)
},
setTasks(state, action) {
return action.payload
}
},
})
export const { toggleImportanceOf, appendTask, setTasks } = taskSlice.actions
export const initializeTasks = () => {
return async dispatch => {
const tasks = await taskService.getAll()
dispatch(setTasks(tasks))
}
}
export const createTask = content => { return async dispatch => { const newTask = await taskService.createNew(content) dispatch(appendTask(newTask)) }}
export default taskSlice.reducer
The principle here is the same: first, an asynchronous operation (createNew
) is executed,
then the action changing the state of the store is dispatched.
The component NewTask
changes as follows:
// ...
import { createTask } from '../reducers/taskReducer'
const NewTask = () => {
const dispatch = useDispatch()
const addTask = async (event) => {
event.preventDefault()
const content = event.target.task.value
event.target.task.value = ''
dispatch(createTask(content)) }
return (
<form onSubmit={addTask}>
<input name="task" />
<button type="submit">add</button>
</form>
)
}
Finally, let's clean up the main.jsx file a bit by moving the code related to the creation of the Redux store into its own, store.js file:
import { configureStore } from '@reduxjs/toolkit'
import taskReducer from './reducers/taskReducer'
import filterReducer from './reducers/filterReducer'
const store = configureStore({
reducer: {
tasks: taskReducer,
filter: filterReducer
}
})
export default store
After the changes, the content of the main.jsx is the following:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
)
The current state of the code for the application can be found on GitHub in the branch part6-5.
Redux Toolkit offers a multitude of tools to simplify asynchronous state management,
like the createAsyncThunk
function
and the RTK Query API.