Effortless Data Fetching and Rendering with Redux Toolkit

Effortless Data Fetching and Rendering with Redux Toolkit

What could be more fun than getting to know our tools by rolling up our sleeves and using them? So, come on—let’s not spend another second! Let’s jump in and start playing around with the code together.

First, let's outline the endpoints we'll be working with. Imagine these endpoints are crafted in a backend language (for instance, I've set up an Express API for this purpose).

constants.js

export const BASE_URL = ''
export const PRODUCTS_URL = '/api/products'

Let's go ahead and construct our apiSlice. You might be wondering, what exactly is a slice? To borrow from the Redux documentation:

💡Redux Slices: Think of a "slice" as a segment of Redux that contains both the reducer logic and actions for a specific feature within your application, usually housed within a single file. This concept is akin to slicing the overall Redux state object into several "slices" of state, each managing its own domain.

💡The createApi function is the essence of what makes RTK Query tick. It empowers you to set up various "endpoints" that articulate the methods for fetching data from backend APIs and asynchronous sources. This includes specifying the procedures for both retrieving and reshaping that data. What you get as a result is an "API slice," which is a structured collection of Redux logic — with the added bonus of React hooks, if you opt for them — that takes care of the data fetching and caching for you.

As it stands, we have yet to define any endpoints. However, as we build each slice moving forward, we will dynamically incorporate endpoints into the apiSlice by using injectEndpoints.

apiSlice.js

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { BASE_URL } from '../constants'

const baseQuery = fetchBaseQuery({ baseUrl: BASE_URL })

export const apiSlice = createApi({
    baseQuery,
    tagTypes: ['Product', 'Order', 'User'],
    endpoints: (builder) => ({})
});

Creating the Redux Store

💡To create the Redux store, we utilize the configureStore function provided by the Redux Toolkit. This function mandates that we supply a reducer parameter.

💡Consider our application as a collection of various features, each potentially having its own distinct reducer function. When invoking configureStore, we have the ability to input these distinct reducers as an object. The keys within this object will correspond to the keys in our ultimate state value.

store.js


import { configureStore } from '@reduxjs/toolkit'
import { apiSlice } from './slices/apiSlice'

const store = configureStore({
    reducer: {
        [apiSlice.reducerPath]: apiSlice.reducer
    },
    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(apiSlice.middleware),
    devTools: true
})

export default store

Now, let's proceed to set up the productApiSlice for data retrieval purposes.

This productApiSlice is designed to handle two specific endpoints: getProducts, which retrieves a list of products, and getProductDetails, which obtains the details of a single product by its ID.

📌Take note that we have not resorted to using any external libraries (like axios or the fetch API) to perform the API calls. This is taken care of by the Redux Toolkit itself.

By utilizing apiSlice.injectEndpoints, we're integrating these two endpoints into the apiSlice.

📌It's also worth highlighting the naming convention used for the exports: useGetProductsQuery, useGetProductDetailsQuery.

The names are derived from the respective endpoints, converted to camelCase, and prefixed with use and suffixed with Query to conform to the conventions.

productsApiSlice.js


import { PRODUCTS_URL } from "../constants";
import { apiSlice } from "./apiSlice";

export const productsApiSlice = apiSlice.injectEndpoints({
    endpoints: (builder) => ({
        getProducts: builder.query({
            query: () => ({
                url: PRODUCTS_URL
            }),
            keepUnusedDataFor: 5
        }),
        getProductDetails: builder. Query({
            query: (productId) => ({
                url: `${PRODUCTS_URL}/${productId}`,
            }),
            keepUnusedDataFor: 5
        })
    })
})

export const { useGetProductsQuery, useGetProductDetailsQuery } = productsApiSlice

Now, let's go ahead and construct the product component, which will be responsible for rendering a product's details.

product.jsx

import React from 'react'
import { Card } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import Rating from './Rating'

const Product = ({ product }) => {

    return (
        <Card className='my-3 p-3 rounded'>
            <Link to={`/product/${product._id}`}>
                <Card.Img src={product.image} variant='top' />
            </Link>
            <Card.Body>
                <Link to={`/product/${product._id}`}>
                    <Card.Title as='div' className='product-title'>
                        <strong>{product.name}</strong>
                    </Card.Title>
                </Link>
                <Card.Text as='div'>
                    <Rating value={product.rating} text={`${product.numReviews} reviews`} />
                </Card.Text>

                <Card.Text as='h3'>
                    ${product.price}
                </Card.Text>
            </Card.Body>
        </Card>
    )
}

export default Product

Now, let's retrieve and display the products by utilizing the endpoints from productsApiSlice.

In HomeScreen.jsx, we can acquire the products with a single line of code by using useGetProductsQuery:

const { data: products, isLoading, error } = useGetProductsQuery();

The data we retrieve will be stored in a variable called products. The isLoading variable remains true during the data fetching process and switches to false once the data is loaded. Should there be any errors, the error variable is designed to capture them, eliminating the need for a try-catch block.

HomeScreen.jsx

import React from 'react'
import { Row, Col } from 'react-bootstrap'
import Product from '../components/Product'
import Loader from '../components/Loader'
import Message from '../components/Message'

import { useGetProductsQuery } from '../slices/productsApiSlice'

const HomeScreen = () => {
  const { data: products, isLoading, error } = useGetProductsQuery()
  return (
    <>
      {isLoading ? (
        <Loader />
      ) : error ? (
        <Message variant='danger'>{error?.data?.message || error.error}</Message>
      ) : (
        <>
          <h1>Latest Products</h1>
          <Row>
            {products.map((product) => {
              return (
                <Col key={product._id} sm={12} md={6} lg={4} xl={3}>
                  <Product product={product} />
                </Col>
              )
            })}
          </Row>
        </>
      )}
    </>
  )
}

export default HomeScreen

Now, let's develop the Product screen, which will be dedicated to rendering the details of a product.

ProductScreen.jsx

import React, { useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { Form, Row, Col, Image, ListGroup, Card, Button } from 'react-bootstrap'
import Rating from '../components/Rating'
import Loader from '../components/Loader'
import Message from '../components/Message'

import { useGetProductDetailsQuery } from '../slices/productsApiSlice'

const ProductScreen = () => {
  let { id: productId } = useParams()
  let navigate = useNavigate()
  const [qty, setQty] = useState(1)
  const { data: product, isLoading, error } = useGetProductDetailsQuery(productId)

  const addToCartHandler = () => {
    navigate(`/cart/${productId}?qty=${qty}`)
  }

  return (
    <>
      <Link className='btn btn-light my-3' to='/'>
        Go Back
      </Link>
      {isLoading ? (
        <Loader />
      ) : error ? (
        <Message variant='danger'>{error?.data?.message || error.error}</Message>
      ) : (
        <Row>
          <Col md={5}>
            <Image src={product.image} alt={product.name} fluid></Image>
          </Col>
          <Col md={4}>
            <ListGroup variant='flush'>
              <ListGroup.Item>
                <h3>{product.name}</h3>
              </ListGroup.Item>
              <ListGroup.Item>
                <Rating value={product.rating ? product.rating : 0} text={`${product.numReviews} reviews`} />
              </ListGroup.Item>
              <ListGroup.Item>Price: ${product.price}</ListGroup.Item>
              <ListGroup.Item>Description: {product.description}</ListGroup.Item>
            </ListGroup>
          </Col>
          <Col md={3}>
            <Card>
              <ListGroup variant='flush'>
                <ListGroup.Item>
                  <Row>
                    <Col>Price:</Col>
                    <Col>
                      <strong>${product.price}</strong>
                    </Col>
                  </Row>
                </ListGroup.Item>
                <ListGroup.Item>
                  <Row>
                    <Col>Status:</Col>
                    <Col>
                      <strong> {product.countInStock > 0 ? 'In stock' : 'Out of stock'}</strong>
                    </Col>
                  </Row>
                </ListGroup.Item>

                {product.countInStock > 0 && (
                  <ListGroup.Item>
                    <Row>
                      <Col>Qty</Col>
                      <Col>
                        <Form.Control as='select' value={qty} onChange={(e) => setQty(e.target.value)}>
                          {[...Array(product.countInStock).keys()].map((x) => (
                            <option key={x + 1} value={x + 1}>
                              {x + 1}
                            </option>
                          ))}
                        </Form.Control>
                      </Col>
                    </Row>
                  </ListGroup.Item>
                )}

                <ListGroup.Item>
                  <Button onClick={addToCartHandler} className='btn-block' disabled={product.countInStock === 0}>
                    Add To Cart
                  </Button>
                </ListGroup.Item>
              </ListGroup>
            </Card>
          </Col>
        </Row>
      )}
    </>
  )
}

export default ProductScreen

With all the pieces in place—from defining our endpoints in the productApiSlice to effortlessly fetching product listings and details with our hooks—it's time to bring it all together on the Product screen. Here we'll take the data we've obtained and craft a user interface that not only showcases product details but also enriches the user experience. It's a testament to the streamlined process and efficiency of using Redux Toolkit in our project, encapsulating complex functionality into readable and maintainable code. As we render each product detail into our Product screen, we can appreciate the seamless integration of data handling and UI rendering that our setup provides.

More to read