Featured resource
pluralsight tech forecast
2025 Tech Forecast

Which technologies will dominate in 2025? And what skills do you need to keep up?

Check it out
Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

Guided: React Foundations - Hooks and Contexts

In this React Fundamentals - Hooks and Contexts guided lab, you will dive deep into advanced React concepts to build and enhance a helpdesk ticketing application. Through hands-on tasks, you will work with asynchronous operations using the useEffect hook and the IndexedDB Web API, and learn to manage shared state efficiently across components with React's Context API. By creating custom hooks and integrating promise-based logic, this lab offers you practical skills in building dynamic, data-driven applications.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 2h 25m
Published
Clock icon Jun 26, 2024

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Overview

    Welcome!

    In this guided lab, you will enhance a simple helpdesk application to persist data to a database. As you perform these tasks, you'll learn how to work with async code, write custom hooks, and share data through the Context API.

    What to Expect

    You should be familiar with how to pass data between components using props and state. This was covered in the second lab, React Foundations: Props and State.

    The JavaScript code in the lab is written in idiomatic React, which is typically ES2015+ syntax. You should be able to work with arrow functions, Promises, ES modules, and let/const.

    info> Stuck on a task? Check out the solution/ folder in the project directory for full solution files and reference code for each step. Files with numbers (like index.2.jsx) correspond to the task order in the step.

    Getting Started

    To make sure the lab environment is set up and working, you'll start by running the Globomantics Helpdesk demo application. Once set up, you can visit {{localhost:5173}} to view the app in your browser, or in the Web Browser tab in the editor to the right.

    What you will see is a plain HTML and CSS webpage from the index.html file.

    info> Web browser not working? Sometimes while reading through the lab, the dev server will shut down and you'll have to reconnect to the Terminal. Just run npm run dev again to start the server back up. Other times, the web browser may not always reflect the latest code due to the auto-save feature of the lab. Just click the refresh button next to the address bar in the tab to force-refresh the page.

    How is the app hosted? You may wonder what "VITE" means and how the app is being hosted on the above URL. This comes from the JavaScript development tool Vite.js.

    Vite is a frontend tool that bundles and builds JavaScript applications, including React. There are other options available depending to build and host React applications depending on what you need, such as Next.js.

    Vite is versatile and supports many other frameworks and libraries, including plain HTML and CSS with very little configuration.

    A Simple Helpdesk App

    The application is a simple helpdesk where you can enter ticket information related to a Globomantics product.

    There are several React components you created previously, including:

    • TicketForm to encapsulate the form fields and handling form submission
    • TicketDisplay to handle displaying submitted ticket information to the user

    Additionally, the file tree has been organized into modules within the src folder so that it's easier to focus on the new code you'll write throughout the lab.

    Where is the data being stored?

    You can try entering ticket information and viewing the submitted data but if you reload the page, the data is gone!

    This is because React state is stored in-memory by default -- you aren't yet persisting it anywhere. A React application is just JavaScript which means you can send data to a database backend through an API, but in this case you will be persisting it to the browser storage which uses the same patterns and practices.

  2. Challenge

    Introducing the useEffect Hook

    Introducing the useEffect Hook

    The database you'll store the data in is called IndexedDB and it's supported by all major browsers through a Web API. It is like a document database which makes it simple to store JavaScript objects (serialized as JSON).

    Pop quiz: Where would you put the code to work with the database in a React component? In the function body?

    You can but that is not the appropriate spot for code like this. Instead, it's better to treat this as a side effect.

    What are side effects?

    A side effect is code that changes the state of things outside React. You can think of a side effect as running code that depends on data in your React component.

    Running code that results in side effects (like creating or opening a database connection) should be run within a special React hook called useEffect.

    Other side effects can include:

    • Sending an API request using fetch
    • Setting the window.location.href property
    • Subscribing to document events
    Is reading data from the URL a side effect?

    In the App component, you read the product from the URL querystring. Is this a side-effect?

    No, not technically, as it doesn't change the state of the page or window.

    However, you may prefer to still wrap this code in a useEffect since it interfaces with an API outside React (window) and may depend on props or state of the component.

    Rule of thumb: if you are writing code against an API outside of React, it probably should be an effect to be safe.

    The useEffect hook accepts two arguments:

    1. A callback which can safely execute JavaScript code that can contain side effects
    2. An array of dependencies. If a dependency changes, the effect will be executed again.

    You can call a useEffect in the body of a functional component, like this:

    import { useEffect } from "react";
    
    const GreetingComponent = ({ greeting }) => {
      useEffect(() => {
        console.log(greeting);
      }, [greeting]);
    	
      return <div>{greeting}</div>
    };
    

    When do you think the console.log statement runs? Anytime greeting changes, including on the initial mount of the GreetingComponent.

    How does React know when to re-run effect callbacks?

    The hook dependency array is very important to understand. React treats the values the same way it treats props, by doing a shallow comparison to determine equality across component updates.

    If the values are equal to the previous values, React will not run the effect callback again. If any of the values differ, it will run the effect again.

    This means there are performance considerations for effects, such as splitting effects based on how often the dependencies change.

    Opening a database connection

    You'll understand how useEffect works better by starting with a simple task: opening a database connection to IndexedDB when the application mounts.

    The IndexedDB API is asynchronous but it does not use Promises! Instead, it uses event handlers similar to how you handled form submission. For example, to open a database, you can write:

    const request = window.indexedDB.open("MyTestDatabase", 1);
    
    request.onsuccess = (event) => {
      // Do something with event.target.result!
    };
    

    Every IndexedDB operation results a similar request interface with onsuccess and onerror callback events you can handle.

    Since the hook dependency array dictates how often an effect runs, it means when you only want to run an effect once you should pass an empty dependency array ([]). This effectively only runs the effect on mount. When you reload the app, you will see in the browser console the following message:

    "Successfully connected to IndexedDB", IDBDatabase
    

    The successful result of connecting to IndexedDB is a reference to the database API object. This contains methods and properties to interact with the database.

    What if you get an error?

    As a browser API, the user can deny permission to store data due to privacy or site settings. This would be the most common case of the request to open a DB failing.

    The other reason it could fail is due to insufficient disk space. The errorCode will tell you what the problem is, and you will need to handle telling the user what to do.

    Sharing the database

    The goal is to store ticket data in the database on submission, which is currently handled in the handleTicketCreated callback in the App component.

    You now have a reference to the database API... but how do you pass it to the other event handler?

    If you are thinking "use component state" than congratulations, you are thinking in React. Go ahead and try to submit a ticket in the browser. Afterwards, you should see the following logs:

    > "Successfully connected to IndexedDB", IDBDatabase
    > IDBDatabase
    

    After the initial console.log in the onsuccess handler, there is another log called from the handleTicketCreated callback. The value was successfully passed through local state to the other handler!

    Now that you have a reference to the database API, you can store the created ticket data.

  3. Challenge

    Creating Custom Hooks

    Creating Custom Hooks

    To store the ticket into IndexedDB, you'll need to use the database API object to do so. However, IndexedDB can be hard to work with if you're unfamiliar and that is not the point of this lab, so to make it simpler, there is a custom hook available for you to work with that uses a Promise-based API.

    What's a custom hook?

    A custom hook is simply a plain JavaScript function whose name starts with use and can wrap any React hook or JavaScript code.

    You may have noticed that the App component is becoming more complex, with both a useEffect hook and a useState hook to store the database connection. These can both be wrapped up into a custom hook which makes it easier for components to consume.

    Creating custom APIs

    The useTicketDb custom hook is defined in the src/hooks/useTicketDb.js module and the hook returns an object representing its API interface with several functions:

    • addTicket(ticket) -- Stores a ticket object. Promise resolves to true if successful.
    • updateTicket(ticket) -- Updates an existing ticket. Promise resolves to true if successful.
    • getTicket(id) -- Retrieves a ticket by its ID and returns a Promise with the resolved ticket object.
    • getAllTickets() -- Retrieves all the tickets stored in the database and resolves to an array of ticket objects

    You can see one of the major benefits of creating custom hooks is defining custom APIs to interface with other services or external state.

    A component now only needs to consume the useTicketDb hook and does not need to know anything about IndexedDB to use it.

    Using a custom hook

    You'll notice the functions are referring to a db object that doesn't exist -- it's missing the connection request code. This is what you just implemented in the previous step so now you will refactor the App component to use this new useTicketDb custom hook API. If you try to submit a ticket again in the Web Browser tab and open the browser console, you'll see the same previous messages logged as before.

    Nothing changed as far as functionality but now the App component is cleaner and you now have access to the addTicket API to create and store the ticket into the database. If you submit a ticket in the Web Browser tab, after 5 seconds in the browser developer console you'll see... an error?

    DOMException: Failed to execute 'transaction' on 'IDBDatabase': One of the specified object stores was not found.

    Uh-oh!

    In order to store ticket data, you actually need to define an "object store" in IndexedDB (a collection of objects).

    Syncing UI with external state

    Notice that even though the addTicket function threw an error, the form was still cleared and the new ticket was displayed in the UI.

    This is because you are still setting local component state using setCreatedTicket before awaiting the call to addTicket.

    The problem currently is that the UI is now out-of-sync with the external state of the database.

    Advanced: Optimistic updates

    You could maintain the current functionality but then roll back the change to local state if it fails to add to the database. This results in a more responsive UX as you are not waiting for the database to confirm a transaction (assuming that in the majority of cases, it will succeed). This is called an "optimistic update" and it's a more advanced way to handle external data requiring much more control over the form state.

    This will fix the out-of-sync issue but you will still need to create an object store in the database when it initializes. To do that, you'll need to implement some code for IndexedDB.
  4. Challenge

    Using Promises in Effects

    Using Promises in Effects

    In modern JavaScript development, many developers prefer working with Promise-based APIs versus event handlers or Node.js-style callbacks. In order to make working with IndexedDB easier, the idb package wraps the native APIs with Promise-based code.

    You can use Promise-based code within useEffect hooks but you cannot pass an async function as the effect callback directly like this:

    /* THIS DOES NOT WORK */
    useEffect(async () => {
      const result = await someAsyncFn(...);
      /* do something */
    }, []);
    

    To work with Promises within useEffect callbacks, you have two options:

    1. Call .then() on any Promises invoked in the effect callback (this is the more classical approach to working with Promises)
    2. Wrap any async/await code with another async function and call that within the hook callback

    Even though it feels less modern, to keep things simple you'll use the first method to refactor your code.

    The Promise.then method accepts two arguments, a success and error handler:

    Promise.then(
      result => { /* on success */ },
      err => { /* on error */ }
    );
    

    If a Promise is resolved, it invokes the success handler. If it is rejected, it invokes the error handler if it is specified, or raises an exception if not.

    Switching to a Promise-based API

    Within the useTicketDb hook, you will replace the event handler code you originally wrote with the idb Promise-based API in the useEffect callback and create a new object store with the upgrade option.

    What does the upgrade option do?

    When you passed the upgrade callback option to openDB, you ended up specifying a way for IndexedDB to identify objects being stored using an id property. Furthermore, you set it to auto-increment. This is common in many databases like MySQL, PostgreSQL, or MongoDB -- an ID is auto-generated when a record is inserted and this is something IndexedDB can handle for you too.

    Now if you try submitting a new ticket, finally, it works!

    Kind of.

    There's actually a noticeable delay of around 5 seconds between when you clicked the Create ticket button and when the data was displayed.

    This introduces a new problem: how do you tell the user something is happening in the background?

    Showing user feedback

    If a user clicks something and nothing happens, they may think your app is broken. Often for form-based asynchronous code, you may disable the submit button and change its content to indicate the user's data is being processed. This has a second-order benefit of preventing accidental or duplicate data from being submitted.

    To accomplish this, you'll need to track the state of the Promise.

    Reflecting Promise state

    Another pop quiz: how would you reflect the fact that a Promise is being resolved during an effect callback?

    You guessed it: local state.

    You can introduce a state flag that reflects whether a request is being processed. It can be initialized to false and then be set to true before waiting for the Promise. When the Promise is resolved or rejected, it can then be reset to false. You could then choose to introduce more state, such as the error message. Now that you're tracking the state of the database creation Promise, how will you reflect it back to the user in the form?

    The handleTicketCreated callback is in the App component, but the submit button is within the TicketForm component.

    You could pass the isCreating state flag down as a prop to the TicketForm component, and then to the <button> element (remember, this is called "prop drilling").

    However, prop drilling tends to add intermediate props that don't have much use and can make prop interfaces confusing. To avoid this, you will take advantage of another React API to share data between components: Contexts.

  5. Challenge

    Introducing the Context API

    Introducing the Context API

    The React Context API allows you to share data throughout the component tree without using props. You can think of it as a useState hook that maintains the same value and can be used anywhere underneath its Context.

    You create a context using the createContext API which accepts an initial value (similar to a useState):

    import { createContext } from "react";
    
    const TicketContext = createContext({
      isCreating: false
    });
    

    Contexts are made up of two parts: the Provider and the Consumer.

    Context providers

    A Context Provider is a component that takes in the current value and sets the context. The reason you need a Provider is so that you can write logic and behavior that changes the state of the context within the React component tree. Notice above that createContext is called once in the module, and is not tied to the React tree.

    A common pattern is to wrap the Provider with a custom component that you export for usage within your React app:

    import { createContext, useState } from "react";
    
    const TicketContext = createContext({
      isCreating: false
    });
    
    export function TicketProvider({ children }) {
      const [isCreating, setIsCreating] = useState(false);
      const contextValue = {
        isCreating,
        setIsCreating
      };
    	
      return (
        <TicketContext.Provider value={contextValue}>
          {children}
        </TicketContext.Provider>
      )
    }
    

    Notice how the TicketProvider is a React component that can use hooks. It wraps any children underneath it with the Context Provider, passing the current value. By wrapping everything in your <App /> component with the new Context provider, you are making the TicketContext available to any consumers.

    Consuming a Context

    To consume a context, you can use the useContext React hook. It accepts a reference to your Context and then its return value is the value of the Context:

    import { useContext } from "react";
    import { MyContext } from "./MyContext";
    
    function MyComponent() {
      const contextValue = useContext(MyContext);
    	
      /* you can now work with the Context */
    }
    

    Rather than exporting the Context itself, a common pattern is to wrap the useContext with a custom hook exported from the same module (so that your Context is not exposed outside the module scope). The useTicketContext hook can now be used anywhere within your application to access the current TicketContext value.

    This means you can replace the useState hook in the App component with your new context API as well as consume the context in the TicketForm component directly to access the isCreating state. You can now try to submit a ticket again in the Web Browser and you should see the button text switch to Creating... for 5 seconds after you click it.

    You feel so proud that you are working with async code and making your app more dynamic that when you reload the page, you forgot you're supposed to show the tickets to the user.

    Oh, that's right! Now that the tickets are being stored in IndexedDB, you can update the UI to display all the tickets in the database when the page loads.

  6. Challenge

    Loading and Displaying Dynamic Data

    Loading and Displaying Dynamic Data

    You now understand how to track the state of async code and how to update the UI dynamically based on that state to show feedback to the user. You're sharing state using the Context API and have a custom hook to work with the database from any component.

    It's time to put everything you've learned to the test now as you add a new section to the UI to display a list of all the tickets in the database, not only when the page loads, but also after creating a ticket. This will create a truly dynamic UX.

    Rendering a data list

    First, add the new TicketList component to your application. The <TicketList /> component renders a list of tickets in a table view. It uses a useEffect hook to fetch the tickets from the database on mount, as you've seen before. It also maps over the ticket array, rendering <TicketListItem /> components using the ticket ID as a key and uses "props spreading" to set the component props using ticket data.

    What is props spreading?

    JSX expressions can contain any JavaScript expression, including the object spread operator (...myVariable).

    Since props on a React component is an object, spreading props allows you to use the properties of an object to set the props of the component en-masse.

    <TicketListItem key={ticket.id} {...ticket} />
    

    Props spreading can sometimes have unintended side-effects, like overwriting previously set props or introducing props that cause excessive rendering so it should be used sparingly.

    With the TicketList added to your app, reloading the page should now display a list of tickets stored in the database!

    But it doesn't!

    Instead it just says:

    No tickets created yet.

    The reason is because the TicketList tries to call getAllTickets when it mounts. However, initializing the database connection is asynchronous so the database is not ready on mount. When the DB is not initialized, getAllTickets simply returns an empty array.

    How can you force the getAllTickets effect to re-run when the DB is initialized?

    Depending on state variables

    There is a simple way to address this and its by specifying the db state as a dependency of the effect hook so that if its value changes, the hook re-runs.

    Since the db can be undefined before its initialized, the effect callback needs to guard against this and return early if that's the case. Now when you reload the page, tickets are listed as the effect waits until db becomes defined.

    However, you aren't done yet. When you create a new ticket, it doesn't get added to the list. Remember, IndexedDB is "external state" so you'll need to notify React when data changes to re-query the list of tickets.

    Using effect hooks for notifications

    The useEffect hook is perfect for this since it's designed to re-run its callback whenever its dependencies change.

    Can you think of a way to depend on ticket changes? Remember that you are tracking the state of ticket creation through the TicketContext now.

    By passing the isCreating state flag from the TicketContext as a dependency to your useEffect hook, you can re-run the getAllTickets function whenever isCreating changes. Fantastic, now the newly created ticket will get added to the list as soon as its inserted into the database!

    It's also displayed underneath the list, but it would be useful to be able to select any ticket in the list to display instead of only the created ticket.

    Currently that is managed within the App component using the createdTicket state.

    Lifting state up to the Context

    Sometimes in React you'll hear the term "lifting state up" which means refactoring a component to move its state higher in the component tree. You would do this in order to share that state more widely and to more tightly control a component's rendering.

    Rather than only tracking the createdTicket, it could be better to track a selectedTicket in the newly-introduced TicketContext. That way, components can get or set which ticket is displayed at any time. You made some changes in multiple places, specifically:

    1. Introduced a new context-level state variable for getting and setting selectedTicket
    2. Refactored App to set the selected ticket to the newly-created ticket
    3. Refactored TicketList to set the selected ticket when a TicketListItem is clicked

    You lifted the currently displayed ticket to the Context (and changed the name for clarity).

    Should you lift `db` up to the Context?

    If you noticed that there are two connection messages in the browser console now, you have a discerning eye. Hooks all have isolated state which means each instance of a hook within a component gets its own state. That means in this case, the openDB method is called twice -- once in App and once in TicketList.

    Lifting db up to the Context won't automatically fix it (because there can be multiple Context providers in the tree). Instead, you'd want to make sure to only create one instance of the db variable. This is called using a singleton design pattern.

    In the Web Browser tab, you should now be able to click the summary of any ticket to display it below the list, and it should still display newly-created tickets after submission as well.

    A working app

    You can now create tickets, select any to display, and persist data across reloads. It seems your work is done!

    After you submit your changes and request a code review, your team lead says:

    Team Lead: Thanks for your hard work! I think there are still some optimizations we could make in places but we'll tackle those at a later time since the app is working fine. This looks great!

    You now have a fully-functioning offline-capable helpdesk application! Congratulations!

  7. Challenge

    Recap and Summary

    Recap

    Here's what you learned in this lab:

    • What hooks and side-effects are in React
    • Implementing side effects using the useEffect hook
    • How to work with async code
    • How to track (and reflect) async state
    • How to write custom hooks
    • How to use createContext and useContext
    • How to display dynamic data
    • How to lift state up using the Context API
    • Re-running effects with dependencies

    This completes the foundational React you'll need to build highly-dynamic and complex web applications!

    While the database you worked with for the lab only works in your local web browser, these same patterns and practices apply to any backend database or API you may integrate with in the real-world.

    Stretch goals

    If you play around with the demo application, you may notice a few things you could change:

    • Only display Loading tickets... the first time tickets are fetched instead of every time after creating a ticket
    • Don't clear the form until after successfully creating a ticket
    • Disable all the form fields during submission
    • Allow selecting tickets through the URL
    • Avoid connecting to the database twice using module-scoped state or moving the db state to the TicketContext

    If you'd like to practice your React skills, apply everything you've learned through this lab series to see if you can accomplish these tasks.

    Where to go next

    After this Foundational series you are ready to dive deeper into intermediate to advanced React topics like:

    • Reducing excessive re-rendering
    • Memoization and the useCallback hook
    • Using state management solutions
    • Suspense-enabled data fetching
    • Refs and DOM manipulation
    • Debugging with the React Dev Tools
    • Writing React Server Components

    These topics and many more are covered throughout other courses and guided labs in the React Learning Path.

Kamran Ayub is a technologist specializing in full-stack web solutions. He's a huge fan of open source and of sharing what he knows.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.