State Management in React - Part 3 of “Learning React as a Frontend Developer with 3.5 Years of Vue Experience”

In this part of our article series, we’re going to go over “Managing State, Props, and Data Flow in React”. But before jumping into state management in React, understanding immutability, how it works, and what that provides us is really essential. So let’s start with that:

What is Immutability and Why Do We Follow That Principle:

The immutability principle basically means that once a data structure is created, it shouldn’t be mutated. Instead of modifying the existing data structure, you create and work with new versions of it.

It is a principle that helps us avoid side effects, simplifying the change detections, and with that, it helps us detect the changes in our state or props with high performance.

To ensure the immutability of our application, we basically never reassign any reactive data to it. Instead of doing that we use the tools React gave us (for example setState on class components or hooks like useState or reducer logic on Redux or any other state management systems). Thanks to them, React can handle shallow comparison, diffing, and re-rendering easily.

By following the immutability principle besides making diff checking a lot easier with shallow comparison, you also ensure that your code remains consistent, your application behaves as expected everywhere, and you can keep track of your data changes easily. That leads to more predictable, maintainable, and efficient code.

Let’s see that on a code example:

// Original object
let user = {
  name: 'Ahmet',
  address: {
    city: 'Sakarya',
    licensePlate: 54
  }
};

// Non-immutable way
user.name = 'Ali';
user.address.city = 'Istanbul';


// Immutable way
user = {
  ...user,
  name: 'Ali',
  address: {
    ...user.address,
    city: 'Istanbul'
  }
};        

Explanation

1. Non-Immutable Way:

  • The properties of the user object are directly modified. This approach violates the immutability principle because it mutates the original object directly.

2. Immutable Way:

  • A new user object is created by spreading the properties of the original user and updating the necessary properties.
  • The user variable is then reassigned to this new object, preserving immutability by not altering the original object.

I think this information is enough for you to use immutability in React. But if you’re a detail-oriented junkie like me, many questions ( “Why” s, “Why not” s, “What are the alternatives” start to fly through your mind ): I smell you dude! You can read my “Learn in 5 Minutes: Understanding Immutability, Reactivity, Shallow and Deep Comparisons” article. I strongly think that would answer most of your questions.

Handling State in Class Components

In class components, our main state handling mechanism is the state object provided by React's Component class and the setState built-in function to handle changes to it. Let's look at a small code example and go through it:

import React, { Component } from 'react';

class MyComponent extends Component {
  constructor() {
    // Initial state
    this.state = {
      count: 0,
      user: {
        name: 'Ahmet',
        city: 'Sakarya'
      }
    };
  }

  incrementCount = () => {
    this.setState({ count: this.state.count + 1 });
  };

  updateUserName = (newName) => {
    this.setState({
      user: {
        ...this.state.user,
        name: newName
      }
    });
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.incrementCount}>Increment Count</button>
        <p>User: {this.state.user.name}</p>
        <button onClick={() => this.updateUserName('Ali')}>Change Name to Ali</button>
      </div>
    );
  }
}

export default MyComponent;        

It already seems pretty simple, right? The only thing we should know and care about is that React’s state management mechanisms, including state and setState, work on the immutability principle. So each time you make a change to the state, a new state object is created, and the state's reference changes.

You should also understand that each time you make a change to any part of the state, you don’t need to shape the entire state object or provide an updated version. You only need to give the part that’s changing, and React will handle it. Since what object destructuring does is also a version of shallow copying, you can think of it like this:

const setState = (prevState, stateUpdates) => {
  let state = {
    ...prevState,
    ...stateUpdates
  };
  return state;
};        

setState function updates the state object using object destructuring, merging the previous state with the new state updates. This ensures that only the parts of the state that need to be updated are changed, keeping the rest of the state intact.

Of course, this is only a conceptual example. This is not the only thing that setState does. It also triggers re-renders and handles asynchronous updates.

Handling State in Functional Components

In functional components just like we highlighted in the first part of this article series, we didn’t have any ability to handle states before the React Hooks. We only had props to take and show them on our presentational functional components.

But with the arrival of React Hooks, we have a way more efficient and modular way to handle state reactively with useState hook.

Unlike the unified state object in class components, where all state variables are grouped together, the useState hook in functional components allows us to manage state variables individually. This approach offers the flexibility to initialize and update each state variable separately, giving us fine-grained control over the state.

In components with complex logic, this separation enhances both readability and performance, as only the specific state variables that need to change are updated, rather than re-rendering an entire state object. This targeted updating can lead to more efficient re-renders, particularly in scenarios where only parts of the state are affected.

function MyComponent() {
  // handling count logic
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount((prevCount) => prevCount + 1);
  };

  // handling user logic
  const [user, setUser] = useState({ name: 'Ahmet', age: 25 });

  const updateUser = (updates) => {
    setUser((prevUser) => ({ ...prevUser, ...updates }));
  };

  return (
    <div>
      <p>Count: {count}</p>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <button onClick={incrementCount}>Increment</button>
      <button onClick={() => updateUser({ name: 'Ali', age: 30 })}>
        Change Name and Age
      </button>
    </div>
  );
}        

Let’s go deep a bit more on the usage of useStatehook:

1. Direct Updates and Functional Updates

useState hook has 2 versions of usage: Direct Updates and Functional Updates.

  • On direct updates, you use the state variable itself instead of previousState and a function to update, you use the state variable itself and give the result directly:

const [count, setCount] = useState(0);

// Direct update
const increment = () => {
  setCount(count + 1);
};        

  • On the Functional Updates, instead of direct usage of the state variable we use the prev.. prefix to access its previous version and use an update function to return the final version.

const [count, setCount] = useState(0);

const incrementCount = () => {
  setCount((prevCount) => prevCount + 1);
};        

Comparison Between Direct and Functional Updates

  • Especially when multiple updates on the same variable, or any asynchronous updates or complex changes exist on your handle it’s always better to use Functional Updates. Since it would always give you the most recent version you’d avoid bugs and confusion. Even though Direct Updates might have easier syntax and might have slight performance differences from the functional updates, in my personal opinion it’s always better to use functional updates to keep with the same pattern everywhere and prevent complex bugs can occurring.

2. Initialization Function and Lazy Initialization

  • If you have a state that needs some calculations for its initial value you can use an initialization function for that. Also if your state variable is not needed on the first render of your page, React will ensure that the state is only initialized when it’s first needed. By this, you can also improve your page’s initial rendering time slightly.

const [count, setCount] = useState(() => computeInitialValue());        

?????? P.S.: Although those benefits of initialization functions and lazy initialization, there are some things that you really need to care about them! Using an initialization function is better to practice only if your state variable doesn’t have to interact with other state variables, and the calculation is synchronous and relatively simple computing. If you have a state that affects each other or need to update multiple states together using the useReducerhook would be a lot more accurate. We’ll also explain the useReducer hook later on in this article. Also if your calculation has asynchronous calculations you should use useEffect hook for those types of calculations which we’ll be handling in the next part of our article series.

3. Batching Updates

Batching in React refers to grouping multiple state updates and reflecting the changes to the DOM with a single re-render. This prevents different state updates from causing unnecessary re-renders, thus improving performance. Batching was introduced in React 18, released in March 2022. Before React 18, each state update would trigger a re-render, which could reduce performance.

Lifting State Up, Passing Props to Children, and One-Way Data Flow

Up until this point, we learned how a component can manage its own state. But sometimes (actually most of the time in real-world examples) components share the data. This means one or more child components may be needed and the same data may be used. To manage that data easily we have props on React.

Lifting state up means, that when multiple components share the same data we lift the data up to the closest and meaningful parent component and pass the required props to relevant children.

By lifting the state up and passing the props to children, we provide the data between the components and prevent code and data repetition.

When we do that centralization and data sharing we use the one-way data flow approach to handle and share data between components without facing synchronization or tracking problems. This means data flows from parent to children and when a change is needed we should make that change on the parent component’s data thus we can sync the data changes between the child components.

In React, using the one-way data flow approach, we manage data centrally in a parent component and track changes in state by creating state-changing functions. These functions are then passed down to child components as props. Instead of directly manipulating the data, the child components invoke these functions to request updates. On the child side, we take the relevant function as a prop and provide the necessary arguments to update the state in the parent component. Let’s take a look at a code example to understand this concept better.

We have a parent component named TaskApp that includes the task data and the necessary functions for adding new tasks and marking them as done, and sending those data and functions to the children as props.

import React, { useState } from "react";

function TaskApp() {
  const [tasks, setTasks] = useState([
    { name: "Learn React", completed: false },
    { name: "Build a project", completed: false },
  ]);

  const addTask = (taskName) => {
    setTasks([...tasks, { name: taskName, completed: false }]);
  };

  const markTaskCompleted = (index) => {
    const updatedTasks = tasks.map((task, i) =>
      i === index ? { ...task, completed: true } : task
    );
    setTasks(updatedTasks);
  };

  return (
    <div>
      <h1>Task Management App</h1>
      <TaskManager tasks={tasks} addTask={addTask} />
      <TaskList tasks={tasks} markTaskCompleted={markTaskCompleted} />
    </div>
  );
}

export default TaskApp;        

We also have a task manager component that we can add new tasks and also we can see the summary of the tasks ending.

import React, { useState } from "react";

const TaskManager = ({ tasks, addTask }) => {
  const [newTask, setNewTask] = useState("");

  const handleAddTask = () => {
    if (newTask.trim()) {
      addTask(newTask);
      setNewTask("");
    }
  };

  const completedTasks = tasks.filter(task => task.completed).length;

  return (
    <div>
      <h3>Task Manager</h3>
      <div>
        <input
          type="text"
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Enter new task"
        />
        <button onClick={handleAddTask}>Add Task</button>
      </div>
      <div>
        <h4>Task Summary</h4>
        <p>Total Tasks: {tasks.length}</p>
        <p>Completed Tasks: {completedTasks}</p>
      </div>
    </div>
  );
}

export default TaskManager;

        

We also have a task list component that we can show the existing tasks and mark them as completed.

import React, { useState } from "react";

const TaskList = ({ tasks, markTaskCompleted }) => {
  return (
    <div>
      <h3>Task List</h3>
      <ul>
        {tasks.map((task, index) => (
          <li key={index} style={{ textDecoration: task.completed ? "line-through" : "none" }}>
            {task.name}
            {!task.completed && (
              <button onClick={() => markTaskCompleted(index)}>Mark as Completed</button>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}        

As you can see both child components took the tasks data and also the functions to update those tasks as props from the parent component and called those functions when they wanted to update the data.

By this, we handle how the data should get updated from the components defined on the parent component and we have direct control over the data. The only thing that child components do is call the functions on the parent with some properties.

Comparison with Vue

Vue’s one-way data flow and controlling props are almost just the same as React's but with 2 differences. Let’s take a look at them:

1. Callback passing vs. Event Emitting

Instead of React’s passing functions as a callbacks approach, in Vue we emit events from children to parents and we listen to those events on the parent component and take the actions that we want due to that event listening.

Let’s take a look at how to handle the same components as in the example up above on a Vue 3 app:

<template>
  <div>
    <h1>Task Management App</h1>
    <TaskManager :tasks="tasks" @add-task="addTask" />
    <TaskList :tasks="tasks" @mark-completed="markTaskCompleted" />
  </div>
</template>

<script>
import { ref } from 'vue';
import TaskManager from './TaskManager.vue';
import TaskList from './TaskList.vue';

export default {
  name: 'TaskApp',
  components: {
    TaskManager,
    TaskList,
  },
  setup() {
    const tasks = ref([
      { name: "Learn Vue 3", completed: false },
      { name: "Build a project", completed: false },
    ]);

    const addTask = (taskName) => {
      tasks.value.push({ name: taskName, completed: false });
    };

    const markTaskCompleted = (index) => {
      tasks.value[index].completed = true;
    };

    return { tasks, addTask, markTaskCompleted };
  },
};
</script>        

Here is our parent TaskApp component on our Vue app. We define the data and functions just like we do in React just with 1 difference we don't send those events to the child components as props instead we listen “add-task” and “mark-completed” events that are emitted from the children and handle them on our parent component. It might seem just the same with React but you’ll understand the difference better when you see the child components.

<template>
  <div>
    <h3>Task Manager</h3>
    <div>
      <input v-model="newTask" placeholder="Enter new task" />
      <button @click="handleAddTask">Add Task</button>
    </div>
    <div>
      <h4>Task Summary</h4>
      <p>Total Tasks: {{ tasks.length }}</p>
      <p>Completed Tasks: {{ completedTasks }}</p>
    </div>
  </div>
</template>

<script>
import { ref, computed } from 'vue';

export default {
  name: 'TaskManager',
  props: {
    tasks: {
      type: Array,
      required: true,
    },
  },
  setup(props, { emit }) {
    const newTask = ref('');

    const handleAddTask = () => {
      if (newTask.value.trim()) {
        emit('add-task', newTask.value);
        newTask.value = '';
      }
    };

    const completedTasks = computed(() =>
      props.tasks.filter(task => task.completed).length
    );

    return { newTask, handleAddTask, completedTasks };
  },
};
</script>        

Here is our TaskManager child component. We only took the tasks as props and when we want to make any changes to it we emit a function to be listened to and handled on the parent component with the properties we want to be used on the event listeners.

<template>
  <div>
    <h3>Task List</h3>
    <ul>
      <li
        v-for="(task, index) in tasks"
        :key="index"
        :style="{ textDecoration: task.completed ? 'line-through' : 'none' }"
      >
        {{ task.name }}
        <button v-if="!task.completed" @click="markCompleted(index)">
          Mark as Completed
        </button>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'TaskList',
  props: {
    tasks: {
      type: Array,
      required: true,
    },
  },
  setup(props, { emit }) {
    const markCompleted = (index) => {
      emit('mark-completed', index);
    };

    return { markCompleted };
  },
};
</script>        

And just the same on the TaskList component. We only take tasks as props and emit events for the data update:

Just like I said their one-way data flow approaches are just the same but on React we’re sending callback functions as props and directly calling them on the children. In Vue instead, we emit events from the children and listen to them on parents and do what we want to do with those emitted events and the data.

Summary

On Vue: Child -> Emit Event -> Parent Listens -> Parent Handles On React: Parent -> Pass Function -> Child Calls Function -> Parent Handles

Vue’s “event emission” approach is more flexible in terms of custom events and decoupling child and parent. You can emit multiple events from the child without needing to know the parent’s implementation details.

React’s “passing callback functions as props” approach makes the data flow more explicit and straightforward but couples the child to the parent’s implementation more directly.

2. Vue’s two-way data binding

In Vue, although prop management follows a one-way data flow similar to React, there’s a handy piece of syntactic sugar called v-model. Vue allows developers to create reusable functionalities known as directives, and v-model is one of the most common built-in directives provided by the Vue core team.

What v-model does is deceptively simple yet powerful: with just one keyword, it allows you to both pass a prop to a child component and listen for changes to that prop, updating the parent’s state accordingly. This might seem like two-way data binding—where the child directly updates the prop—but it isn’t. Vue strictly enforces a one-way data flow. If you try to mutate a prop directly in the child component, Vue will warn you with the following message:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten
whenever the parent component re-renders. Instead, use a data or computed
property based on the prop's value. Prop being mutated: "<propName>"        

Another common confusion arises when passing an object as a prop. If you modify the object’s properties within the child component, those changes reflect in the parent. This happens because the object is passed by reference, not because Vue encourages direct mutation. Vue doesn’t enforce immutability, which allows these changes, but it’s not the recommended approach — it’s more of an unintended side effect.

Contrary to what some might believe, v-model doesn’t give children the ability to directly update a prop. It’s merely syntactic sugar. Under the hood, v-model sends the data as a prop and also adds an event listener for the prop’s update event. For example:

<template>
  <div>
    <ChildComponent v-model="parentValue" />
  </div>
</template>        

When we send our parentValue to the child component with v-model, we actually use a syntactic shortcut for this:

<template>
  <div>
    <ChildComponent :modelValue="parentValue" @update:modelValue="parentValue = $event" />
  </div>
</template>        

And when we want to update the value from the child component only thing we need to do is emit an update event for that value with new data like this:

this.$emit('update:modelValue', newValue);        

While v-model significantly reduces boilerplate by handling much of this behind the scenes, it doesn’t break the one-way data flow model that Vue adheres to. This is somewhat akin to how Redux Toolkit simplifies state management. Though it may seem like you’re mutating the state directly, Redux Toolkit uses Immer to enforce immutability under the hood. Similarly, v-model in Vue gives the illusion of two-way binding while still adhering to one-way data flow.

Using the useReducer Hook for Complex State Structures

The useReducer hook is typically used for two main purposes:

  1. Handling Complex State Logic: When you have multiple interdependent variables, using individual useState setters can become cumbersome. Instead, useReducer allows you to centralize and manage all state changes within a single reducer function. This is particularly useful in scenarios like managing a shopping cart, where adding or removing items may require updating multiple pieces of state simultaneously.
  2. Managing Dynamic Forms: In forms with many fields, especially those with nested structures, useReducer offer a dynamic way to handle updates. Rather than defining and managing state for each individual form field, you can use a reducer to handle all state updates in a more organized and scalable manner.

The general flow of using useReducer starts with defining the initial state. Then, similar to Redux, you write a reducer function that handles different action types and their corresponding state mutations. Within the component, you create the state and dispatch elements from the reducer and initial state, allowing you to access necessary variables and dispatch actions as needed.

While useReducer closely mirrors the logic of Redux, it is more suitable for situations where you need component-level state management with complex logic but without the need for state access across different levels of the application.

Let’s dive into the code examples and make what we talk about more clear with them.

1. Handling Complex State Logic: When you think of an example of a complex state with a change that affects more than one state variable the shopping cart is generally when you see the most common example of this scenario for using useReducer. The shopping cart includes an excellent complexity and one change affects more than one state variable but generally in real-world examples many components need and use the cart data on different levels so in real world using Redux with Shopping Cart would be a lot more accurate. But when I think of real-world code examples about this scenario I couldn’t find a direct and easy-to-write example for it. When I asked ChatGPT for examples it gave me the filters and the complexity was just fitting on useReducer . Still, most of the real-world code examples at least in the companies I work, have their filtering components as separate and dynamic for sharing across all lists so again I felt like it was not a perfect real-world code example. But since we’re just trying to show how we can handle complex data with useReducer I think it’s okay to use Shopping Cart example here to give a brief showcase for useReducer:

import React, { useReducer } from 'react';

const initialState = {
  items: [],
  totalAmount: 0,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const updatedItems = [...state.items, action.item];
      const updatedAmount = state.totalAmount + action.item.price;
      return {
        ...state,
        items: updatedItems,
        totalAmount: updatedAmount,
      };
    case 'REMOVE_ITEM':
      const filteredItems = state.items.filter(item => item.id !== action.id);
      const itemToRemove = state.items.find(item => item.id === action.id);
      const reducedAmount = state.totalAmount - itemToRemove.price;
      return {
        ...state,
        items: filteredItems,
        totalAmount: reducedAmount,
      };
    default:
      return state;
  }
}

const ShoppingCart = () => {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItemToCart = (item) => {
    dispatch({ type: 'ADD_ITEM', item });
  };

  const removeItemFromCart = (id) => {
    dispatch({ type: 'REMOVE_ITEM', id });
  };

  return (
    <div>
      <h2>Shopping Cart</h2>
      <ul>
        {state.items.map((item) => (
          <li key={item.id}>
            {item.name} - ${item.price}
            <button onClick={() => removeItemFromCart(item.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <p>Total Amount: ${state.totalAmount}</p>
      {/* Add logic to add items to the cart */}
      <button onClick={() => addItemToCart({ id: 1, name: 'Apple', price: 1 })}>
        Add Apple
      </button>
    </div>
  );
}

export default ShoppingCart;        

2. Managing Dynamic Forms: When you have a form with multiple steps and a formData with many variables in it using a dynamic field setter would be very beneficial for reducing the code complexity and making the with just one dynamic setter. Also, you can manage multiple steps and their info easily by using useReducer:

import React, { useReducer } from 'react';

const initialState = {
  currentStep: 1,
  formData: {
    name: '',
    age: '',
    favoriteColor: '',
    reason: '',
  },
  errors: {},
  requiredFields: {
    1: ['name'], // Only 'name' is required in step 1
    2: ['age'],  // Only 'age' is required in step 2
  },
};

const formReducer = (state, action) => {
  switch (action.type) {
    case 'NEXT_STEP':
      return { ...state, currentStep: state.currentStep + 1, errors: {} };
    case 'PREV_STEP':
      return { ...state, currentStep: state.currentStep - 1 };
    case 'UPDATE_FIELD':
      return {
        ...state,
        formData: { ...state.formData, [action.field]: action.value },
      };
    case 'VALIDATE_FIELDS':
      const errors = {};
      const currentRequiredFields = state.requiredFields[state.currentStep] || [];
      currentRequiredFields.forEach((field) => {
        if (!state.formData[field]) {
          errors[field] = `${field.charAt(0).toUpperCase() + field.slice(1)} is required`;
        }
      });
      return { ...state, errors };
    case 'RESET_FORM':
      return initialState;
    default:
      return state;
  }
};

const MultiStepForm = () => {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleNext = () => {
    dispatch({ type: 'VALIDATE_FIELDS' });
    if (Object.keys(state.errors).length === 0) {
      dispatch({ type: 'NEXT_STEP' });
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    dispatch({ type: 'UPDATE_FIELD', field: name, value });
  };

  return (
    <div>
      {state.currentStep === 1 && (
        <div>
          <input
            name="name"
            value={state.formData.name}
            onChange={handleChange}
            placeholder="Name"
          />
          {state.errors.name && <p>{state.errors.name}</p>}
          <button onClick={handleNext}>Next</button>
        </div>
      )}
      {state.currentStep === 2 && (
        <div>
          <input
            name="age"
            value={state.formData.age}
            onChange={handleChange}
            placeholder="Age"
          />
          {state.errors.age && <p>{state.errors.age}</p>}
          <button onClick={() => dispatch({ type: 'PREV_STEP' })}>
            Back
          </button>
          <button onClick={handleNext}>Next</button>
        </div>
      )}
      {state.currentStep === 3 && (
        <div>
          <input
            name="favoriteColor"
            value={state.formData.favoriteColor}
            onChange={handleChange}
            placeholder="Favorite Color"
          />
          <input
            name="reason"
            value={state.formData.reason}
            onChange={handleChange}
            placeholder="Why?"
          />
          <button onClick={() => dispatch({ type: 'PREV_STEP' })}>
            Back
          </button>
          <button onClick={() => dispatch({ type: 'RESET_FORM' })}>
            Reset
          </button>
        </div>
      )}
    </div>
  );
}

export default MultiStepForm;        

Conclusion

This part of the article was an entry into state and prop management on React. The next part would be about Prop Drilling and Advanced State Management Ways in React with the content of Context API and Redux for sharing state between components without passing props too many levels and handling side effects with useEffect and usage of useMemo hook. Thank you for your time and for reading the whole article. Hope to see you in the next part ?.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了