Data Sharing in React Applications (PART 1): Refetching, Prop Drilling, Context API, and Beyond.

Data Sharing in React Applications (PART 1): Refetching, Prop Drilling, Context API, and Beyond.

·

12 min read

Introduction

In React applications, efficiently sharing data between components is essential for building robust and maintainable user interfaces. Whether it's passing data down from parent components to their children, or enabling communication between sibling components, choosing the right method for data sharing can greatly impact the performance of your application. In this three-part article series, we shall explore four common approaches for sharing data in React: refetching data, prop drilling, Context API and React Query.

Part one of this series will focus on the first two methods: refetching data and prop drilling. Refetching data involves making redundant network requests to retrieve the same data in multiple components. We call the requests redundant because the data being queried has previously been accessed by the application.

Prop drilling on the other hand involves passing data through multiple layers of components to reach its destination. We'll explore the advantages and disadvantages of each method, along with best practices and potential pitfalls to avoid.

In part two, we'll dive into Context API, a powerful inbuilt feature in React for managing global state and enabling data sharing across the component tree without the need to drill props. We'll discuss how Context API works, when to use it, and how it compares to the techniques discussed in this article.

Lastly, in part three, we'll explore React Query, a library specifically designed for handling server state and caching in React applications. We'll discuss how React Query optimizes network requests, manages remote data, and facilitates efficient data sharing between components, providing a comprehensive solution for managing data in complex React applications.

By the end of this series, you'll have a deep understanding of various strategies for sharing data between components in your React applications, enabling you to make better-informed decisions and build more efficient and scalable user interfaces.

Project Setup

We have this simple React setup.

We have a table on the left displaying superheroes and their professions. The data is fetched from an API. On the right side is a form allowing users to add new superheroes and their corresponding occupations to the API. These newly added superheroes are then dynamically rendered on the table.

Above these two components is a counter, updating in real-time to reflect the current number of superheroes listed in the table.

This is the current code setup in App.jsx:

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';

const App = () => {
    return (
        <div className="app">
            <SuperheroCount />
            <div>
                <SuperheroList />
                <AddSuperhero />
            </div>
        </div>
    );
};

export default App;

The function responsible for fetching the list of superheroes is located within the SuperheroList component, while the function for adding superheroes is within the AddSuperhero component. This structure seems ideal as each function is encapsulated within the component where it's needed.

Here is the code for SuperheroList component:

const SuperheroList = () => {
    const [superheroes, setSuperheroes] = useState([]);

    useEffect(() => {
        fetch('http://localhost:3000/superheroes')
            .then((res) => res.json())
            .then(setSuperheroes);
    }, [superheroes]);

    return (
        <table>
            <thead>
                <tr>
                    <th>Superhero</th>
                    <th>Occupation</th>
                </tr>
            </thead>
            <tbody className="border border-slate-300">
                {superheroes.map((superhero) => (
                    <tr key={superhero.id}>
                        <td>{superhero.name}</td>
                        <td>{superhero.occupation}</td>
                    </tr>
                ))}
            </tbody>
        </table>
    );
};

export default SuperheroList;

And here is the code for the AddSuperhero component:

const AddSuperhero = () => {
    const handleSubmit = (e) => {
        e.preventDefault();

        const formData = new FormData(e.target);
        const data = Object.fromEntries(formData);

        fetch('  http://localhost:3000/superheroes', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(data),
        });
    };
    return (
        <form onSubmit={handleSubmit}>
            <label htmlFor="name">Superhero Name</label>
            <input type="text" id="name" name="name" required />

            <label htmlFor="occupation">Superhero Occupation</label>
            <input type="text" id="occupation" name="occupation" required />

            <button>Add Superhero</button>
        </form>
    );
};

export default AddSuperhero;

However, we immediately encounter the first issue, one you might have already spotted from the screenshot I shared; the count of superheroes is not displaying. This is because the SuperheroCount component requires access to the length of the superheroes' array from the SuperheroList component in order to display the current superhero count. Let's get started with the first of our two approaches.

Re-fetch the data in the relevant component.

One approach is to re-fetch the data in the relevant component, in this case the SuperheroCount component. We could make an additional query inside this component to access the length of the superhero array:

import { useEffect, useState } from 'react';

const SuperheroCount = () => {
    const [superheroes, setSuperheroes] = useState([]);

    useEffect(() => {
        fetch('http://localhost:3000/superheroes')
            .then((res) => res.json())
            .then(setSuperheroes);
    }, [superheroes]);
    return <div>Total Superheroes: {superheroes.length} </div>;
};

export default SuperheroCount;

This method functions effectively, providing us with an accurate superhero count.

It remains functional even when adding new superheroes. For instance, upon adding Black Widow and Captain America, the count updates instantly as we want:

That is good.

To reduce redundancy, we can abstract our fetch function into a custom hook. This hook which already contains our base URL only requires the specific endpoint as input.

import { useEffect, useState } from 'react';

export const useFetch = (endpoint) => {
    const [data, setData] = useState([]);

    useEffect(() => {
        fetch(`http://localhost:3000/${endpoint}`)
            .then((res) => res.json())
            .then(setData);
    }, [data, endpoint]);

    return { data };
};

By utilizing this custom hook in both components, our code becomes more concise while still maintaining intended functionality.

Below is how we have consumed it in the SuperheroList component:

import { useFetch } from '../hooks/useFetch';

const SuperheroList = () => {
    const { data: superheroes } = useFetch('superheroes');

    return (
        <table>
            {/*same as before*/}
        </table>
    );
};

export default SuperheroList;

We use it similarly in the SuperheroCount component:

import { useFetch } from '../hooks/useFetch';

const SuperheroCount = () => {
    const { data: superheroes } = useFetch("superheroes");

    return <div>Total Superheroes: {superheroes.length} </div>;
};

export default SuperheroCount;

While we have confirmed this approach achieves the desired outcome, it has several drawbacks that make it less than ideal. Some of these are:

  1. Redundant Network Requests: Each time we refetch the data in the SuperheroCount component, we are making an unnecessary network request because we already had access to the array's length in the SuperheroList component. We could simply find a way to share that data with our SuperheroCount component instead of making a fresh request which can lead to increased latency and decreased performance, especially if the data were large or the network connection was slow.

  2. Code Duplication: Refetching the same superheroes data in multiple components violates the DRY (Don't Repeat Yourself) principle, as we've duplicated the code to fetch and handle the data. Even with a custom hook which significantly reduced the amount of code we had to write, we still found ourselves writing the exact same code (copy-pasta?) twice in different components.

  3. Wasted Resources: In production network requests consume resources, including bandwidth and server resources. Refetching the same data unnecessarily wastes these resources and can lead to increased costs, particularly in cloud-based environments where we may be charged based on resource usage.

Unless this miraculously so happens to be the only way left to achieve world peace, I would strongly advise against using this approach in your code.

Lifting state and prop drilling

To recap our challenge: We're working with a number of React components to display and manage data queried from an API. We want to share the data fetched from the API between sibling components. While one component SuperheroesList handles the fetching and rendering of the data, the sibling component SuperheroesCount requires access to the length of the fetched array.

Having seen that our first approach is inefficient and in production would be very expensive, let us explore lifting state up and prop drilling as an alternative.

Lifting state up involves moving the data you want to be shared between different components state to a parent or common ancestor component, enabling descendant components to access and update it as needed. In our current scenario, the App.jsx does not know what is happening inside SuperheroesCount or SuperheroesList components.

Let us observe the sample component tree below:

A is the topmost component. It is an ancestor to all components in the app. Any state and/or data declared inside it can be made accessible to any of the components in our app. If we declare data or state component H, it is accessible to I and J because they both are its descendant. And so on.

In our file structure, both SuperheroesCount and SuperheroesList components are found in the App.jsx file, which makes App.jsx their parent.

main.jsx is an ancestor of the SuperheroesList, SuperheroesCount and though not shown in this tree illustration, AddSuperhero components.

Prop drilling entails passing the necessary data down from an ancestor through the component hierarchy via props to where they are needed.

Where we have data needed by sibling components as is our case, we could lift state to a parent or ancestor and then drill the data down to each sibling.

Still utilizing our custom hook, we can invoke it once inside App.jsx and drill the superheroes data queried down to the two siblings that need it.

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';
import { useFetch } from './hooks/useFetch';


const App = () => {
    const { data: superheroes } = useFetch('superheroes');
    return (
        <div className="app">
            <SuperheroCount superheroes={superheroes}/>
            <div>
                <SuperheroList superheroes={superheroes} />
                <AddSuperhero />
            </div>
        </div>
    );
};

export default App;

We then inform each sibling component to expect to receive a superheroes prop from their parent. First in the SuperheroList component:

const SuperheroList = ({superheroes}) => {
    return (
        <table>
            {/*same as before*/}
        </table>
    );
};

export default SuperheroList;

And in the SuperheroCount component:

const SuperheroCount = ({superheroes}) => {
    return <div>Total Superheroes: {superheroes.length} </div>;
};

export default SuperheroCount;

If you have followed the steps as outlined here, our code should work as before.

We have reduced code duplication and redundant network calls by making just one request in the parent component and drilling the data down to its children.

However, while this approach might be okay in small projects, it is highly discouraged in larger projects.

Let us briefly go back to this illustration:

Assume we had some data needed by both I and J so we declared it in H and drilled the props down to both children. As we continue developing our application, we soon realize that F also needs access to the same data, wholly or in part.

Now we move the declaration (we lift state) from component H up to component C and like before, drill the data down through G, H, and finally, to the two components that need it, i.e I and J.

Sometime later, as the application keeps growing, we realize components D and E also need access to the same data we had lifted up to C. Say this data is login information and D and E are protected pages that need to know the identity of the logged-in user to determine whether they are authorized to view those pages.

Or maybe those two pages just need to extract the name of the logged-in user so they can display a custom greeting using that user's name, such as "Good morning, Oyier!"

What do we do? Once more, we lift state from component C to the topmost ancestor component A and drill the relevant data.

Now imagine this illustration did not just end at component J but kept growing to component Z, which also needs data from A. Initially, it only needed the name of the logged-in user. Then it further evolves and now needs five other pieces of information from the data, and as a developer, you have to drill each of those through each component that links A to component Z.

You quickly see how much convoluted code we would need to write to avail this data to Z.

Passing props can become verbose and inconvenient when you need to pass some prop deeply through the tree, or if many components need the same prop. The nearest common ancestor could be far removed from the components that need data, and lifting state upthat high can lead to a situation called “prop drilling”.

And that is not even mentioning other downsides to prop drilling, which we can summarize below:

  1. Complexity and Maintenance Issues: As applications grow, prop drilling can lead to a tangled web of props being passed through multiple components, making it difficult to track where data is coming from and where it's going. This complexity can make the application harder to maintain and refactor.

  2. Component Reusability: Prop drilling can reduce the reusability of components. Components that are heavily reliant on props passed from their parents are less likely to be used in other parts of the application without significant modifications.

  3. Tight Coupling: It creates a tight coupling between components that are otherwise unrelated, except for the data being passed down the component tree. This dependency can make it challenging to update or refactor one component without affecting others.

  4. Performance Concerns: In some cases, prop drilling can lead to unnecessary re-rendering of components that do not directly need the data but are part of the chain of components through which data is passed. This inefficiency can impact performance, especially in large and complex component trees.

  5. Difficulty in State Management: Managing state becomes more cumbersome as you need to handle it in higher-level components that may not directly relate to the data itself, leading to bloated components and convoluted logic.

  6. Boilerplate Code: It can lead to an increase in boilerplate code, as you find yourself passing the same props down through multiple layers of components, which can clutter your codebase and make it less readable.

  7. Developer Experience: Prop drilling can negatively affect the developer experience by making it harder to trace data flow through the application, requiring developers to navigate through multiple files and components to understand how data is being passed and used.

Conclusion

We have seen two approaches to sharing data across our application. Both approaches covered in this article, while offering immediate solutions for data propagation, have shown significant drawbacks that can undermine the efficiency, maintainability, and performance of large-scale applications.

These approaches not only impact performance but also scale poorly as the application grows and the data requirements become more complex.

Recognizing the limitations of these approaches, the next article in our series will introduce the Context API as a more efficient and scalable solution for data sharing in React applications. The Context API provides a way to share values like these between components without having to explicitly pass a prop through every level of the tree. This eliminates the core issues associated with prop drilling and redundant data fetching.

See you in the next article to learn how the Context API can streamline your data-sharing strategy and improve both the performance and overall structure of your React projects.

Happy coding! 😊