Skip to content

c

Communicating with a server in a Redux application

Setting up a JSON Server

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 -p 3001 db.json",
  // ...
}

We can now 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 i axios, the console output will be:

$ npm i axios

added 23 packages, and audited 302 packages in 1s

71 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

So, 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/tasks.js:

const baseUrl = 'http://localhost:3001/tasks'

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch tasks')
  }

  const data = await response.json()
  return data
}

export default { getAll }

Let's take a closer look at getAll. The tasks are now fetched from the backend by calling the fetch() function with 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 tasks')
}

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.

Notice 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. Also remember that response.json() is an asynchronous method, so the await keyword is required.

Let's further simplify the code by directly returning the data from response.json():

const getAll = async () => {
  const response = await fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch tasks')
  }

  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 tasks fetched from the server. 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 add an action creator called setTasks, which allows us to directly replace the array of tasks. We can create the desired action creator using the createSlice function as follows:

// ...

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))
    },
    setTasks(state, action) {      return action.payload    }  }
})

export const { createTask, toggleImportanceOf, setTasks } = taskSlice.actionsexport default taskSlice.reducer

Let's implement the initialization of tasks in the App component. As usual, when fetching data from a server, we will use the useEffect hook:

import { useEffect } from 'react'import { useDispatch } from 'react-redux'import taskService from './services/tasks'import { setTasks } from './reducers/taskReducer'
import TaskForm from './components/TaskForm'
import Tasks from './components/Tasks'
import VisibilityFilter from './components/VisibilityFilter'
import { setTasks } from './reducers/taskReducer'import taskService from './services/tasks'
const App = () => {
  const dispatch = useDispatch()
  useEffect(() => {    taskService.getAll().then(tasks => dispatch(setTasks(tasks)))  }, [dispatch])
  return (
    <div>
      <TaskForm />
      <VisibilityFilter />
      <Tasks />
    </div>
  )
}

export default App

The tasks are:

  1. fetched from the server using the getAll() method we defined, and then
  2. stored in the Redux store by dispatching the action returned by the setTasks action creator.

These operations are performed inside the useEffect hook, meaning they are executed when the App component is rendered for the first time.

Pertinent:: 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

We can do the same thing when it comes to creating a new task. This will also give us an opportunity to practice how to make a POST request using the fetch() method.

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 fetch(baseUrl)

  if (!response.ok) {
    throw new Error('Failed to fetch tasks')
  }

  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 task')  }    return await response.json()}
export default { getAll, createNew }

Let's take a closer look at the implementation of the createNew method. fetch()'s first parameter specifies the URL to send the request to. fetch()'s second parameter defines other details with the request, such as the:

  • request type
  • headers, and
  • data.

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 task')
  }
  
  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 task')
}

If the request is successful, JSON Server returns the newly created task, 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 TaskForm component so that a new task is sent to the backend. 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 TaskForm = (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 TaskForm

When a new task is created in the backend by calling the createNew() method, the return value is an object representing the task, to which the backend has generated a unique id. Therefore, let's modify the action creator createTask defined in tasksReducer.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 TaskForm would create a new task as follows:

const TaskForm = () => {
  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 Redux Toolkit's configureStore.

With Redux Thunk, it is possible to implement action creators that return a function instead of an object. This allows implementations of asynchronous action creators that:

  1. wait for the completion of a specific asynchronous operation
  2. and only then dispatch an action, which changes the store's state.

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 initializeTasks in the taskReducer.js file, which fetches the initial tasks from the server, as follows:

import { createSlice } from '@reduxjs/toolkit'
import taskService from '../services/tasks'
const taskSlice = createSlice({
  name: 'tasks',
  initialState: [],
  reducers: {
    createTask(state, action) {
      state.push(action.payload)
    },
    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 ? changedTask : task))
    },
    setTasks(state, action) {
      return action.payload
    },
  },
})

const { setTasks } = taskSlice.actions
export const initializeTasks = () => {  return async (dispatch) => {    const tasks = await taskService.getAll()    dispatch(setTasks(tasks))  }}
export const { createTask, toggleImportanceOf } = taskSlice.actions
export default taskSlice.reducer

In its inner function, that is, in the asynchronous action, the operation first fetches all tasks from the server (getAll) and then dispatches the setTasks action to add the tasks to the store. It is notable that Redux automatically passes a reference to the dispatch method as an argument to the function, so the action creator initializeTasks does not require any parameters.

The action creator setTasks is no longer exported outside the module, since the initial state of the tasks will now be set using the asynchronous action creator initializeTasks we created. However, we still use the setTasks action creator within the module.

The component App can now be defined as follows:

import { useEffect } from 'react'
import { useDispatch } from 'react-redux'

import TaskForm from './components/TaskForm'
import Tasks from './components/Tasks'
import VisibilityFilter from './components/VisibilityFilter'
import { initializeTasks } from './reducers/taskReducer'
const App = () => {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(initializeTasks())  }, [dispatch])

  return (
    <div>
      <TaskForm />
      <VisibilityFilter />
      <Tasks />
    </div>
  )
}

export default App

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 create an asynchronous action creator called appendTask:

import { createSlice } from '@reduxjs/toolkit'
import taskService from '../services/tasks'

const taskSlice = createSlice({
  name: 'tasks',
  initialState: [],
  reducers: {
    createTask(state, action) {
      state.push(action.payload)
    },
    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 ? changedTask : task))
    },
    setTasks(state, action) {
      return action.payload
    },
  },
})

const { createTask, setTasks } = taskSlice.actions
export const initializeTasks = () => {
  return async (dispatch) => {
    const tasks = await taskService.getAll()
    dispatch(setTasks(tasks))
  }
}

export const appendTask = (content) => {  return async (dispatch) => {    const newTask = await taskService.createNew(content)    dispatch(createTask(newTask))  }}
export const { toggleImportanceOf } = taskSlice.actions
export default taskSlice.reducer

The principle is the same: first, an asynchronous operation (createNew) is executed, and once it is completed, an action that updates the store's state is dispatched.

FYI: The createTask action creator is no longer exported outside the file; it is used only internally in the implementation of the appendTask function.

The component TaskForm changes as follows:

import { useDispatch } from 'react-redux'
import { appendTask } from '../reducers/taskReducer'
const TaskForm = () => {
  const dispatch = useDispatch()

  const addTask = async (event) => {
    event.preventDefault()
    const content = event.target.task.value
    event.target.task.value = ''
    dispatch(appendTask(content))  }

  return (
    <form onSubmit={addTask}>
      <input name="task" />
      <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, like the createAsyncThunk function and the RTK Query API.