c
Communicating with server in a Redux application
Setting up JSON Server
Let's expand the application so that the notes 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:
{
"notes": [
{
"content": "the app state is in redux store",
"important": true,
"id": 1
},
{
"content": "state changes are made with actions",
"important": false,
"id": 2
}
]
}We'll install json-server for the project:
npm install json-server --save-devand add the following line to the scripts part of the file package.json
"scripts": {
"server": "json-server -p 3001 db.json",
// ...
}Now let's launch json-server with the command npm run server.
Fetch API
In software development, it is often necessary to consider whether a certain functionality should be implemented using an external library or whether it is better to utilize the native solutions provided by the environment. Both approaches have their own advantages and challenges.
In the earlier parts of this course, we used the Axios library to make HTTP requests. Now, let's explore an alternative way to make HTTP requests using the native Fetch API.
It is typical for an external library like Axios to be implemented using other external libraries. For example, if you install Axios in your project with the command npm install axios, the console output will be:
$ npm install axios
added 23 packages, and audited 302 packages in 1s
71 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesSo, in addition to the Axios library, the command would install over 20 other npm packages that Axios needs to function.
The Fetch API provides a similar way to make HTTP requests as Axios, but using the Fetch API does not require installing any external libraries. Maintaining the application becomes easier when there are fewer libraries to update, and security is also improved because the potential attack surface of the application is reduced. The security and maintainability of applications is discussed further in part 7 of the course.
In practice, requests are made using the fetch() function. The syntax used differs somewhat from Axios. We will also soon notice that Axios has taken care of some things for us and made our lives easier. However, we will now use the Fetch API, as it is a widely used native solution that every Full Stack developer should be familiar with.
Getting data from the backend
Let's create a method for fetching data from the backend in the file src/services/notes.js:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
const data = await response.json()
return data
}
export default { getAll }Let's take a closer look at the implementation of the getAll method. The notes are now fetched from the backend by calling the fetch() function, which is given the backend's URL as an argument. The request type is not explicitly defined, so fetch performs its default action, which is a GET request.
Once the response has arrived, the success of the request is checked using the response.ok property, and an error is thrown if necessary:
if (!response.ok) {
throw new Error('Failed to fetch notes')
}The response.ok attribute is set to true if the request was successful, meaning the response status code is between 200 and 299. For all other status codes, such as 404 or 500, it is set to false.
Note that fetch does not automatically throw an error even if the response status code is, for example, 404. Error handling must be implemented manually, as we have done here.
If the request is successful, the data contained in the response is converted to JSON format:
const data = await response.json()fetch does not automatically convert any data included in the response to JSON format; the conversion must be done manually. It is also important to note that response.json() is an asynchronous method, so the await keyword.
Let's further simplify the code by directly returning the data returned by the response.json() method:
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()}Initializing the store with data fetched from the server
Let's now modify our application so that the application state is initialized with notes fetched from the server.
In the file noteReducer.js, change the initialization of the notes state so that by default there are no notes:
const noteSlice = createSlice({
name: 'notes',
initialState: [], // ...
})Let's add an action creator called setNotes, which allows us to directly replace the array of notes. We can create the desired action creator using the createSlice function as follows:
// ...
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
const content = action.payload
state.push({
content,
important: false,
id: generateId()
})
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find(n => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important
}
return state.map(note => (note.id !== id ? note : changedNote))
},
setNotes(state, action) { return action.payload } }
})
export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actionsexport default noteSlice.reducerLet's implement the initialization of notes in the App component. As is usually the case when fetching data from a server, we will use the useEffect hook:
import { useEffect } from 'react'import { useDispatch } from 'react-redux'
import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { setNotes } from './reducers/noteReducer'import noteService from './services/notes'
const App = () => {
const dispatch = useDispatch()
useEffect(() => { noteService.getAll().then(notes => dispatch(setNotes(notes))) }, [dispatch])
return (
<div>
<NoteForm />
<VisibilityFilter />
<Notes />
</div>
)
}
export default AppThe notes are fetched from the server using the getAll() method we defined, and then stored in the Redux store by dispatching the action returned by the setNotes action creator. These operations are performed inside the useEffect hook, meaning they are executed when the App component is rendered for the first time.
Let's take a closer look at a small detail. We have added the dispatch variable to the dependency array of the useEffect hook. If we try to use an empty dependency array, ESLint gives the following warning: React Hook useEffect has a missing dependency: 'dispatch'. What does this mean?
Logically, the code would work exactly the same even if we used an empty dependency array, because dispatch refers to the same function throughout the execution of the program. However, it is considered good programming practice to add all variables and functions used inside the useEffect hook that are defined within the component to the dependency array. This helps to avoid unexpected bugs.
Sending data to the backend
Next, let's implement the functionality for sending a new note to the server. This will also give us an opportunity to practice how to make a POST request using the fetch() method.
Let's extend the code in src/services/notes.js that handles communication with the server as follows:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
const createNew = async (content) => { const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), }) if (!response.ok) { throw new Error('Failed to create note') } return await response.json()}
export default { getAll, createNew }Let's take a closer look at the implementation of the createNew method. The first parameter of the fetch() function specifies the URL to which the request is made. The second parameter is an object that defines other details of the request, such as the request type, headers, and the data sent with the request. We can further clarify the code by storing the object that defines the request details in a separate options variable:
const createNew = async (content) => {
const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), } const response = await fetch(baseUrl, options)
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}Let's take a closer look at the options object:
- method defines the type of the request, which in this case is POST
- headers defines the request headers. We add the header 'Content-Type': 'application/json' to let the server know that the data sent with the request is in JSON format, so it can handle the request correctly
- body contains the data sent with the request. You cannot directly assign a JavaScript object to this field; it must first be converted to a JSON string by calling the JSON.stringify() function
As with a GET request, the response status code is checked for errors:
if (!response.ok) {
throw new Error('Failed to create note')
}If the request is successful, JSON Server returns the newly created note, for which it has also generated a unique id. However, the data contained in the response still needs to be converted to JSON format using the response.json() method:
return await response.json()Let's then modify our application's NoteForm component so that a new note is sent to the backend. The component's addNote method will change slightly:
import { useDispatch } from 'react-redux'
import { createNote } from '../reducers/noteReducer'
import noteService from '../services/notes'
const NoteForm = (props) => {
const dispatch = useDispatch()
const addNote = async (event) => { event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
const newNote = await noteService.createNew(content) dispatch(createNote(newNote)) }
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
export default NoteFormWhen a new note is created in the backend by calling the createNew() method, the return value is an object representing the note, to which the backend has generated a unique id. Therefore, let's modify the action creator createNote defined in notesReducer.js as follows:
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload) },
// ..
},
})Changing the importance of notes 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 quite good, but it is not great that the communication with the server happens inside the functions of the components. 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(initializeNotes())
}, [dispatch])
// ...
}and NoteForm would create a new note as follows:
const NoteForm = () => {
const dispatch = useDispatch()
const addNote = async (event) => {
event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
dispatch(createNote(content))
}
// ...
}In this implementation, both components would dispatch an action without the need to know about the communication with the server that 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 or even installation when the Redux store is created using the Redux Toolkit's configureStore function.
Thanks to Redux Thunk, it is possible to define action creators that return a function instead of an object. This makes it possible to implement asynchronous action creators that first wait for some asynchronous operation to complete and only then dispatch the actual action.
If an action creator returns a function, Redux automatically passes the Redux store's dispatch and getState methods as arguments to the returned function. This allows us to define an action creator called initializeNotes in the noteReducer.js file, which fetches the initial notes from the server, as follows:
import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload)
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find((n) => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important,
}
return state.map((note) => (note.id !== id ? note : changedNote))
},
setNotes(state, action) {
return action.payload
},
},
})
const { setNotes } = noteSlice.actions
export const initializeNotes = () => { return async (dispatch) => { const notes = await noteService.getAll() dispatch(setNotes(notes)) }}
export const { createNote, toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducerIn its inner function, that is, in the asynchronous action, the operation first fetches all notes from the server and then dispatches the action to add the notes to the store. It is noteworthy that Redux automatically passes a reference to the dispatch method as an argument to the function, so the action creator initializeNotes does not require any parameters.
The action creator setNotes is no longer exported outside the module, since the initial state of the notes will now be set using the asynchronous action creator initializeNotes we created. However, we still use the setNotes action creator within the module.
The component App can now be defined as follows:
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { initializeNotes } from './reducers/noteReducer'
const App = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(initializeNotes()) }, [dispatch])
return (
<div>
<NoteForm />
<VisibilityFilter />
<Notes />
</div>
)
}
export default AppThe solution is elegant. The initialization logic for the notes has been completely separated from the React component.
Next, let's create an asynchronous action creator called appendNote:
import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload)
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find((n) => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important,
}
return state.map((note) => (note.id !== id ? note : changedNote))
},
setNotes(state, action) {
return action.payload
},
},
})
const { createNote, setNotes } = noteSlice.actions
export const initializeNotes = () => {
return async (dispatch) => {
const notes = await noteService.getAll()
dispatch(setNotes(notes))
}
}
export const appendNote = (content) => { return async (dispatch) => { const newNote = await noteService.createNew(content) dispatch(noteSlice.actions.createNote(newNote)) }}
export const { toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducerThe principle is the same once again. First, an asynchronous operation is performed, and once it is completed, an action that updates the store's state is dispatched. The createNote action creator is no longer exported outside the file; it is used only internally in the implementation of the appendNote function.
The component NoteForm changes as follows:
import { useDispatch } from 'react-redux'
import { appendNote } from '../reducers/noteReducer'
const NoteForm = () => {
const dispatch = useDispatch()
const addNote = async (event) => {
event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
dispatch(appendNote(content)) }
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}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. Suitable tools for this use case are for example the createAsyncThunk function and the RTK Query API.