Marcell Ciszek Druzynski

Data flow in React

Learn how to share and manage data between components in React.

July 23, 2024

In React and other modern JavaScript frameworks, we use components much like Lego blocks to build our applications. These components represent self-contained and reusable pieces of logic and code that are responsible for rendering specific parts of the user interface (UI).

Just as each Lego block has its own purpose in constructing a larger Lego creation, every component in an application has its distinct role. This modular approach allows for a more organized and maintainable codebase, where each component can be developed, tested, and debugged independently. By combining these components, we can build complex and dynamic applications in a structured and efficient manner.

Components in React come with their own internal mechanisms for managing state. This state can be any piece of data that changes over time, following the component's rendering cycle and lifetime. By leveraging state, components become reactive, meaning they automatically re-render whenever the state is updated. This reactivity allows us to reflect the UI changes dynamically for the end-user.

In React, we use the useState hook, a commonly used hook that enables functional components to maintain and update their state. Here's a quick example of how useState works:

1import React, {useState} from "react";
2
3function Counter() {
4 const [count, setCount] = useState(0);
5
6 return (
7 <div>
8 <p>You clicked {count} times</p>
9 <button onClick={() => setCount(count + 1)}>Click me</button>
10 </div>
11 );
12}
1import React, {useState} from "react";
2
3function Counter() {
4 const [count, setCount] = useState(0);
5
6 return (
7 <div>
8 <p>You clicked {count} times</p>
9 <button onClick={() => setCount(count + 1)}>Click me</button>
10 </div>
11 );
12}

In this example, the useState hook initializes the count state variable to 0. The setCount function allows us to update the count value. Every time the button is clicked, setCount updates the state, causing the component to re-render and display the new count.

This simple yet powerful mechanism allows developers to create interactive and dynamic user interfaces with ease. By effectively managing state within components, we can build applications that respond seamlessly to user interactions.

But how do we share state and data with other components for example child components that needs to read the state and perhaps also need to update state?

Share and manage data between components

Components in React often have relationships that dictate how data flows through the application. Typically, data is passed from parent components down to child components using props, which work similarly to parameters passed to functions in JavaScript.

Here's an example:

1function ChildComponent(props) {
2 return (
3 <div>
4 <button onClick={props.onClick}>{props.children}</button>
5 </div>
6 );
7}
1function ChildComponent(props) {
2 return (
3 <div>
4 <button onClick={props.onClick}>{props.children}</button>
5 </div>
6 );
7}

In this example, the child component receives props from its parent. The parent component owns the logic for onClick and the children that are always accessible via props.

Props allow components to communicate with their children by passing data. This data can be read-only or read/write, such as a setState function passed as a prop.

Using a setState function, child components can send information back to the parent component, usually triggered by an action or event in the child component that the parent needs to be aware of.

Communicating via the Children to the Parent:

1function App() {
2 const [isOn, setIsOn] = useState(false);
3
4 return (
5 <div>
6 <h1>{isOn ? "ON" : "OFF"}</h1>
7 <Child toggleOn={() => setIsOn(!isOn)} />
8 </div>
9 );
10}
11
12function Child({toggleOn}) {
13 return (
14 <div>
15 <button onClick={toggleOn}>Click here</button>
16 </div>
17 );
18}
1function App() {
2 const [isOn, setIsOn] = useState(false);
3
4 return (
5 <div>
6 <h1>{isOn ? "ON" : "OFF"}</h1>
7 <Child toggleOn={() => setIsOn(!isOn)} />
8 </div>
9 );
10}
11
12function Child({toggleOn}) {
13 return (
14 <div>
15 <button onClick={toggleOn}>Click here</button>
16 </div>
17 );
18}

In this example, we pass down the toggleOn function to the <Child /> component. When the user clicks the button, the child component signals to the parent that the state needs to be updated. This is how we can communicate from a child component to a parent component in React.

Issue with Passing Data as Props

When working on a large application with many components, managing props can become challenging. Every component that receives props needs to declare them, and often, these props are just passed down to other components. If there's a change, like no longer needing a specific prop, we have to remove it from all the components that used it. This can be tedious and error-prone.

Props drilling works but comes with a cost, specially in larger React applications. It makes the code less maintainable harder to read and grasp about what is going on, tracking the data flow of the application.

Context API

A way to avoid the prop drilling problem is to take the advantage of the Context API.

The Context API allows us to share data between components without passing props at every level. By creating a context object and using it in a Provider component, you can wrap your component tree and make the data available to any component that needs it. To access this data, use the useContext() hook.

Passing and reclining props without prop drilling.

  1. Create a Context: First, create a context using React.createContext.

  2. Create a Provider Component: Create a provider component that will use the context's Provider to pass down values.

  3. Consume the Context: Use the useContext hook to consume the context in a functional component.

Step 1: Create a Context

1import React, {createContext, useState} from "react";
2
3// Create the context
4const MyContext = createContext();
5
6// Create a provider component
7const MyProvider = ({children}) => {
8 const [value, setValue] = useState("Hello, World!");
9
10 return (
11 <MyContext.Provider value={{value, setValue}}>
12 {children}
13 </MyContext.Provider>
14 );
15};
16
17export {MyContext, MyProvider};
1import React, {createContext, useState} from "react";
2
3// Create the context
4const MyContext = createContext();
5
6// Create a provider component
7const MyProvider = ({children}) => {
8 const [value, setValue] = useState("Hello, World!");
9
10 return (
11 <MyContext.Provider value={{value, setValue}}>
12 {children}
13 </MyContext.Provider>
14 );
15};
16
17export {MyContext, MyProvider};

Step 2: Wrap the App with the Provider

1import {MyProvider} from "./MyContext";
2import MyComponent from "./MyComponent";
3
4const App = () => {
5 return (
6 <MyProvider>
7 <MyComponent />
8 </MyProvider>
9 );
10};
11
12export default App;
1import {MyProvider} from "./MyContext";
2import MyComponent from "./MyComponent";
3
4const App = () => {
5 return (
6 <MyProvider>
7 <MyComponent />
8 </MyProvider>
9 );
10};
11
12export default App;

Step 3: Consume the Context

1import {useContext} from "react";
2import {MyContext} from "./MyContext";
3
4const MyComponent = () => {
5 const {value, setValue} = useContext(MyContext);
6
7 const handleChange = () => {
8 setValue("Hello, React!");
9 };
10
11 return (
12 <div>
13 <p>{value}</p>
14 <button onClick={handleChange}>Change Value</button>
15 </div>
16 );
17};
18
19export default MyComponent;
1import {useContext} from "react";
2import {MyContext} from "./MyContext";
3
4const MyComponent = () => {
5 const {value, setValue} = useContext(MyContext);
6
7 const handleChange = () => {
8 setValue("Hello, React!");
9 };
10
11 return (
12 <div>
13 <p>{value}</p>
14 <button onClick={handleChange}>Change Value</button>
15 </div>
16 );
17};
18
19export default MyComponent;

Complete Code Structure

1import React, {createContext, useState} from "react";
2
3// Create the context
4const MyContext = createContext();
5
6// Create a provider component
7const MyProvider = ({children}) => {
8 const [value, setValue] = useState("Hello, World!");
9
10 return (
11 <MyContext.Provider value={{value, setValue}}>
12 {children}
13 </MyContext.Provider>
14 );
15};
16
17export {MyContext, MyProvider};
1import React, {createContext, useState} from "react";
2
3// Create the context
4const MyContext = createContext();
5
6// Create a provider component
7const MyProvider = ({children}) => {
8 const [value, setValue] = useState("Hello, World!");
9
10 return (
11 <MyContext.Provider value={{value, setValue}}>
12 {children}
13 </MyContext.Provider>
14 );
15};
16
17export {MyContext, MyProvider};

App.js:

1import React from "react";
2import {MyProvider} from "./MyContext";
3import MyComponent from "./MyComponent";
4
5const App = () => {
6 return (
7 <MyProvider>
8 <MyComponent />
9 </MyProvider>
10 );
11};
12
13export default App;
1import React from "react";
2import {MyProvider} from "./MyContext";
3import MyComponent from "./MyComponent";
4
5const App = () => {
6 return (
7 <MyProvider>
8 <MyComponent />
9 </MyProvider>
10 );
11};
12
13export default App;

MyComponent.js:

1import React, {useContext} from "react";
2import {MyContext} from "./MyContext";
3
4const MyComponent = () => {
5 const {value, setValue} = useContext(MyContext);
6
7 const handleChange = () => {
8 setValue("Hello, React!");
9 };
10
11 return (
12 <div>
13 <p>{value}</p>
14 <button onClick={handleChange}>Change Value</button>
15 </div>
16 );
17};
18
19export default MyComponent;
1import React, {useContext} from "react";
2import {MyContext} from "./MyContext";
3
4const MyComponent = () => {
5 const {value, setValue} = useContext(MyContext);
6
7 const handleChange = () => {
8 setValue("Hello, React!");
9 };
10
11 return (
12 <div>
13 <p>{value}</p>
14 <button onClick={handleChange}>Change Value</button>
15 </div>
16 );
17};
18
19export default MyComponent;

With this setup, MyComponent is able to consume and update the context value provided by MyProvider. The context value changes from* "Hello, World!" _to* "Hello, React!" _when the button is clicked. We could achieve this without sending the props through prop drilling, it is a more elegant and easier way to share data between our components.

The Context API is great for handling themes and internationalization in your app. It lets you access what you need in your components easily.

However, there are some things to watch out for. While the Context API helps avoid prop drilling, it's not ideal for managing global state across your app.

  1. Performance Issues:

    • Re-rendering: When the context value changes, all components that consume the context will re-render. This can lead to performance issues, especially if there are many consumers or if the context value changes frequently.
    • Optimization Challenges: It's challenging to optimize updates to the context value. Techniques like React.memo and useMemo might help, but they require careful application.
  2. Complexity in Large Applications:

    • State Management: While Context API is useful for simple state management, it can become unwieldy for managing complex state logic across a large application. In such cases, dedicated state management libraries like Redux or MobX might be more appropriate.
    • Nested Providers: Using multiple contexts can lead to deeply nested providers, making the component tree harder to read and maintain.
  3. Testing Difficulties:

    • Mocking Context: Testing components that use the Context API can be more complex, as you need to mock the context values and providers properly.
    • Isolation: Isolating component tests can be challenging when they depend on multiple contexts.
  4. Tight Coupling:

    • Component Dependence: Components that consume context are tightly coupled to the context, making it harder to reuse them outside of the context or in different contexts.
    • API Changes: Changes in the context API might require significant refactoring in components that consume the context.
  5. Context Bloat:

    • Overuse: It's easy to overuse context, leading to a situation where the context object becomes bloated with too many values, making it harder to manage and understand.

While the Context API is a powerful tool in React for passing and share data, it should be used judiciously, especially in large applications. For complex state management needs, considering other state management solutions might be beneficial. Proper attention to performance optimization, testing strategies, and avoiding overuse can help mitigate some of the downsides associated with the Context API.

Suitable State Managers

Most large React applications need some kind of state management to share data between components. Whether it's a global state or a more localized state depends on the application's use case. This is where dedicated state management libraries come into play.

Some popular libraries include:

These libraries help manage state throughout the application flow. Redux has been the most popular choice for a long time, but newer libraries like Zustand and Recoil has latley joined the competition. While Redux has updated its API to be more user-friendly, compared how it was when it was first released.

Thanks to state manager libraries, we can avoid prop drilling and share data between components in a more structured way. For example usin Zustand:

1import create from "zustand";
2
3const useStore = create((set) => ({
4 count: 0,
5 increment: () => set((state) => ({count: state.count + 1})),
6 decrement: () => set((state) => ({count: state.count - 1})),
7}));
8
9export default useStore;
1import create from "zustand";
2
3const useStore = create((set) => ({
4 count: 0,
5 increment: () => set((state) => ({count: state.count + 1})),
6 decrement: () => set((state) => ({count: state.count - 1})),
7}));
8
9export default useStore;

In this example, we create a store using Zustand, which provides a count state variable and two functions to increment and decrement the count.

1import useStore from "./useStore";
2
3function Counter() {
4 const count = useStore((state) => state.count);
5 const increment = useStore((state) => state.increment);
6 const decrement = useStore((state) => state.decrement);
7
8 return (
9 <div>
10 <p>Count: {count}</p>
11 <button onClick={increment}>Increment</button>
12 <button onClick={decrement}>Decrement</button>
13 </div>
14 );
15}
1import useStore from "./useStore";
2
3function Counter() {
4 const count = useStore((state) => state.count);
5 const increment = useStore((state) => state.increment);
6 const decrement = useStore((state) => state.decrement);
7
8 return (
9 <div>
10 <p>Count: {count}</p>
11 <button onClick={increment}>Increment</button>
12 <button onClick={decrement}>Decrement</button>
13 </div>
14 );
15}

In the Counter component, we use the useStore hook to access the count state and the increment and decrement functions. When the buttons are clicked, the count state is updated accordingly. This is a simple example, but Zustand can handle more complex state management scenarios.

I won't go into the details of how each library works, but their documentation is excellent. Each has its own approach to managing and sharing state/data between components.

Data Fetching

While there are great state managers out there, it's worth reconsidering before using them. Today, we have modern data-fetching libraries like:

These libraries come with built-in caching that can be reused throughout your application. If your app's primary concern is data fetching, it might be better to use one of these libraries instead of a state manager. The true power of these libraries lies in their ability to cache data and avoid redundant network requests.

Here's an example using React Query:

1import {useQuery} from "react-query";
2
3const fetchPosts = async () => {
4 const response = await fetch("https://jsonplaceholder.typicode.com/posts");
5 return response.json();
6};
7
8function Posts() {
9 const {data, isLoading, error} = useQuery("posts", fetchPosts);
10
11 if (isLoading) return <p>Loading...</p>;
12 if (error) return <p>Error: {error.message}</p>;
13
14 return (
15 <div>
16 <h1>Posts</h1>
17 <ul>
18 {data.map((post) => (
19 <li key={post.id}>{post.title}</li>
20 ))}
21 </ul>
22 </div>
23 );
24}
1import {useQuery} from "react-query";
2
3const fetchPosts = async () => {
4 const response = await fetch("https://jsonplaceholder.typicode.com/posts");
5 return response.json();
6};
7
8function Posts() {
9 const {data, isLoading, error} = useQuery("posts", fetchPosts);
10
11 if (isLoading) return <p>Loading...</p>;
12 if (error) return <p>Error: {error.message}</p>;
13
14 return (
15 <div>
16 <h1>Posts</h1>
17 <ul>
18 {data.map((post) => (
19 <li key={post.id}>{post.title}</li>
20 ))}
21 </ul>
22 </div>
23 );
24}

In this example, we use the useQuery hook from React Query to fetch posts from an API. The fetchPosts function fetches the data, and the useQuery hook handles the loading state, error handling, and caching of the data. This makes it easy to fetch and display data in your components without worrying about managing the state or caching the data.

Data Fetching with Caching

Data fetching with caching is a powerful technique that can significantly improve the performance and user experience of your application. By caching data locally, you can avoid redundant network requests and ensure data consistency across your application. We can even use that cache to share data between components.

With the introduction of React Server Components (RSC) in React 19, the way we think and write React applications changes significantly. We can now fetch data only on the server and send the required data to the components that need it. A common misstake is to mix RSCs with server-side rendering (SSR) and think its the same thing, but it's not. RSCs are a new way to fetch data on the server and send it to the client, while SSR is about rendering the components on the server and sending the HTML to the client.

Combining powerful data-fetching libraries with RSCs enhances the developer experience, making it easier and more efficient to write React applications. By leveraging caching mechanisms and server-side data fetching, you can build applications that are faster, more responsive, and more reliable.

React has went towards a more server-side rendering approach, with the introduction of RSCs. By just opt in the client components(like a button), when needed to be rendered on the client, by explicitly telling React to render it on the client, and make every component a server component by default, we can fetch data on the server and send it to the client.

This approach can significantly improve the performance of your application, as you only fetch the data you need and avoid redundant network requests.

Summary

In the realm of React development, managing data flow between components is a crucial aspect to ensure efficient and maintainable code. The blog post delves into three primary methods for sharing data between components: prop drilling, state management libraries, and data fetching with caching. I briefly covered those techniques to give you a quick overview.

Prop Drilling

Prop drilling involves passing data from a parent component to a deeply nested child component through intermediary components. While straightforward, this method can lead to cumbersome code as the application grows, making it harder to maintain and understand. Key points to remember:

  • Direct Control: Prop drilling gives you direct control over data flow.
  • Scalability Issues: Be cautious as it can become unmanageable in larger applications.

State Management Libraries

To overcome the limitations of prop drilling, state management libraries like Redux, MobX, and Zustand are often employed. These libraries provide a more structured approach to managing state across the application, offering features such as centralized state storage, middleware for side effects, and more.

  • Centralized State: State management libraries centralize the application state, making it easier to access and manage.
  • Middleware: They often include middleware support, simplifying complex state transitions and asynchronous operations.
  • Maintainability: These tools enhance maintainability by decoupling state management from component hierarchy.

Data Fetching with Caching

Data fetching with caching is another important technique discussed. Libraries like React Query and SWR facilitate efficient data fetching by providing built-in caching mechanisms. This approach ensures that data is not redundantly fetched, enhancing performance and user experience.

  • Efficiency: Caching reduces redundant network requests, improving performance.
  • Consistency: Ensures data consistency across the application by synchronizing state with the server.
  • Ease of Use: These libraries abstract away the complexities of data fetching, providing simple APIs for common operations.

Key Takeaways

  1. Understand Prop Drilling: Useful for simple data flows but can lead to unwieldy code in large applications.
  2. Utilize State Management Libraries: Essential for scalable and maintainable applications, offering centralized state and middleware support.
  3. Implement Data Fetching with Caching: Improves performance and user experience by avoiding redundant data requests and ensuring data consistency.

By comprehending and effectively implementing these methods, React developers can enhance the data flow within their applications, leading to more efficient, scalable, and maintainable codebases.