Immutable updates

In order to detect what to rerender, immutable patterns can be used. If the view is a function of the state; and the state is immutable; then we can do a simple === (referential equality) comparison to determine if a rerender is needed.

How immutable updates work

What is immutability?

If something is immutable it means it cannot be changed. For JavaScript, this means you are not allowed to modify an existing object. Instead, you must create a new object and use that going forward. While the new object has not yet replaced the existing one, you may still modify it. Then, you can copy some existing properties into the new object. After you finish creating the new value of the object, you replace the existing one.

Why do React state updates need immutability?

React takes advantage of immutability when checking if a rerender is needed. Whenever you call a React state setter function, a component and all its children will rerender, given that (a) the state that has been set is different from the current state and (b) none of the children is memoized. Let's focus on (a) for now; checking if the state is different. React does this with a strict equality check (===). This check is very cheap, because is compares the reference identity of the object.

Note

An alternative would be comparing the value of every property of the object to the same property on a previous object. Looping over these object properties would require a considerable amount of time if we are dealing with complex (nested) state.

Because we are using immutable updates, we can guarantee that:

  • If the contents of the state after the update are different from the contents of the state before the update, the reference identity will be different.

  • If some part of the state remain the same, these parts will keep the same identity.

Immutable update patterns for React State

Please take a look at the comments in the code for each example.

Simple Object

How to update a simple object with multiple keys. Each key has a primitive value (a string in this case). Here we only read and update; we don't delete or create state.

import { useState } from "react";

export default function App() {
	const [todo, setTodo] = useState({
		message: "",
		dueDate: "",
	});

	function changeMessage(newMessage) {
		// We use a functional state update.
		setTodo((currentTodo) => {
			// We create a new object ({})
			return {
				// We use the object spread operator (...) to put the current value of the
				// todo state into the new object. This means we will put both the
				// 'message' and 'dueDate' property into the new object.
				...currentTodo,
				// Now we overwrite the 'message' property we just copied from the old object.
				message: newMessage,
			};
		});
	}

	function changeDueDate(newDueDate) {
		setTodo((currentTodo) => {
			return {
				...currentTodo,
				dueDate: newDueDate,
			};
		});
	}

	return (
		<div>
			<input
				type="text"
				value={todo.message}
				onChange={(e) => changeMessage(e.target.value)}
			/>
			<input
				type="date"
				value={todo.dueDate}
				onChange={(e) => changeDueDate(e.target.value)}
			/>
			<pre>state: {JSON.stringify(todo, null, 2)}</pre>
		</div>
	);
}

Nested Object

A slightly more complex example. Here the state is an object which contains several other objects.

import { useState } from "react";

export default function App() {
	const [todo, setTodo] = useState({
		message: {
			title: "",
			body: "",
		},
		author: {
			firstName: "",
			lastName: "",
		},
		dueDate: "",
	});

	function changeMessageBody(newBody) {
		// We use a functional state update.
		setTodo((currentTodo) => {
			// We create a new object ({})
			return {
				// We use the object spread operator (...) to put the current value of the
				// todo state into the new object. This means we will put the 'message',
				// 'dueDate' and 'author' properties into the new object.
				...currentTodo,
				// Now we overwrite the 'message' property we just copied from the old object.
				// We also use the spread operator for this new message. This way both 'title'
				// and 'body' will remain unchanged.
				message: {
					...currentTodo.message,
					// Then, we change the body of the message to the new body.
					body: newBody,
				},
			};
		});
	}

	function changeMessageTitle(newTitle) {
		setTodo((currentTodo) => {
			return {
				...currentTodo,
				message: {
					...currentTodo.message,
					title: newTitle,
				},
			};
		});
	}

	function changeAuthorFirstName(newFirstName) {
		setTodo((currentTodo) => {
			return {
				...currentTodo,
				author: {
					...currentTodo.author,
					firstName: newFirstName,
				},
			};
		});
	}

	function changeAuthorLastName(newLastName) {
		setTodo((currentTodo) => {
			return {
				...currentTodo,
				author: {
					...currentTodo.author,
					lastName: newLastName,
				},
			};
		});
	}

	function changeDueDate(newDueDate) {
		setTodo((currentTodo) => {
			return {
				...currentTodo,
				dueDate: newDueDate,
			};
		});
	}

	return (
		<div>
			<input
				type="text"
				placeholder="message body"
				value={todo.message.body}
				onChange={(e) => changeMessageBody(e.target.value)}
			/>
			<input
				type="text"
				placeholder="message title"
				value={todo.message.title}
				onChange={(e) => changeMessageTitle(e.target.value)}
			/>
			<input
				type="text"
				placeholder="first name"
				value={todo.author.firstName}
				onChange={(e) => changeAuthorFirstName(e.target.value)}
			/>
			<input
				type="text"
				placeholder="last name"
				value={todo.author.lastName}
				onChange={(e) => changeAuthorLastName(e.target.value)}
			/>
			<input
				type="date"
				value={todo.dueDate}
				onChange={(e) => changeDueDate(e.target.value)}
			/>
			<pre>state: {JSON.stringify(todo, null, 2)}</pre>
		</div>
	);
}

Array

An example with an array

import { useState } from "react";

export default function App() {
	// each message will have type string
	const [messages, setMessages] = useState([]);

	/**
	 * updates a message in the messages array
	 * @param {number} editedMessageIndex the index of the message item in the array that should be changed
	 * @param {string} editedMessage the value of the message after the update.
	 */
	function changeMessage(editedMessageIndex, editedMessage) {
		// We use a functional state update.
		setMessages((currentMessages) => {
			// We create a new array by mapping over the old array. Array.map returns a new array.
			// For more information about mapping, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
			return currentMessages.map((message, messageIndex) => {
				// map loops over each array item and invokes this callback for each item.
				// what we return from the callback will be the value at the same index in
				// the new array.

				// If the indexes are the same, it means this is the item
				// that should be edited.
				if (messageIndex === editedMessageIndex) {
					return editedMessage;
				}

				// otherwise, if the indexes are not the same, it means we are
				// dealing with an array item that should remain the same.
				// So we just return it as is.
				return message;
			});
		});
	}

	function deleteMessage(indexToDelete) {
		setMessages((currentMessages) => {
			// We use filter create a new array with only the items that should remain.
			// For more info about filter, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
			return currentMessages.filter((message, messageIndex) => {
				// If the indexes are the same, it means this is the item
				// that should be deleted.
				if (messageIndex === indexToDelete) {
					// returning false means it will be filtered out of the new array
					return false;
				}

				// otherwise, if the indexes are not the same, it means we are
				// dealing with an array item that should remain.
				return true;
			});
		});
	}

	function addMessage() {
		setMessages((currentMessages) => {
			// All existing messages stay the same when adding a message; so we don't have to use map.
			// Instead, we create a new array ([]) and spread the existing values onto it with the
			// array spread operator (...).
			// After that, we insert an empty string at the end of the new array.
			return [...currentMessages, ""];
		});
	}

	return (
		<div>
			{messages.map((message, messageIndex) => {
				return (
					<div>
						<input
							type="text"
							placeholder="message"
							value={message}
							onChange={(e) => changeMessage(messageIndex, e.target.value)}
						/>
						<button
							type="button"
							onClick={() => {
								deleteMessage(messageIndex);
							}}
						>
							Delete
						</button>
					</div>
				);
			})}
			<button
				type="button"
				onClick={() => {
					addMessage();
				}}
			>
				Add
			</button>
			<pre>state: {JSON.stringify(messages, null, 2)}</pre>
		</div>
	);
}

Array with Objects

import { useState } from "react";

export default function App() {
	// Each todo looks as follows:
	// {
	//   message: "",
	//   dueDate: "",
	// }
	const [todos, setTodos] = useState([]);

	/**
	 * updates a todo message in the todos array
	 */
	function changeTodoMessage(editedTodoIndex, editedMessage) {
		// We use a functional state update.
		setTodos((currentTodos) => {
			// We create a new array by mapping over the old array. Array.map returns a new array.
			// For more information about mapping, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
			return currentTodos.map((todo, index) => {
				// map loops over each array item and invokes this callback for each item.
				// what we return from the callback will be the value at the same index in
				// the new array.

				// If the indexes are the same, it means this is the item
				// that should be edited.
				if (index === editedTodoIndex) {
					return {
						...todo,
						message: editedMessage,
					};
				}

				// otherwise, if the indexes are not the same, it means we are
				// dealing with an array item that should remain the same.
				// So we just return it as is.
				return todo;
			});
		});
	}

	/**
	 * updates a todo due date in the todos array
	 */
	function changeTodoDueDate(editedTodoIndex, editedDueDate) {
		// We use a functional state update.
		setTodos((currentTodos) => {
			// We create a new array by mapping over the old array. Array.map returns a new array.
			// For more information about mapping, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
			return currentTodos.map((todo, index) => {
				// map loops over each array item and invokes this callback for each item.
				// what we return from the callback will be the value at the same index in
				// the new array.

				// If the indexes are the same, it means this is the item
				// that should be edited.
				if (index === editedTodoIndex) {
					return {
						...todo,
						dueDate: editedDueDate,
					};
				}

				// otherwise, if the indexes are not the same, it means we are
				// dealing with an array item that should remain the same.
				// So we just return it as is.
				return todo;
			});
		});
	}

	function deleteTodo(indexToDelete) {
		setTodos((currentTodos) => {
			// We use filter create a new array with only the items that should remain.
			// For more info about filter, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
			return currentTodos.filter((todo, index) => {
				// If the indexes are the same, it means this is the item
				// that should be deleted.
				if (index === indexToDelete) {
					// returning false means it will be filtered out of the new array
					return false;
				}

				// otherwise, if the indexes are not the same, it means we are
				// dealing with an array item that should remain.
				return true;
			});
		});
	}

	function addTodo() {
		setTodos((currentTodos) => {
			// All existing todos stay the same when adding a todo; so we don't have to use map.
			// Instead, we create a new array ([]) and spread the existing values onto it with the
			// array spread operator (...).
			// After that, we insert an empty todo at the end of the new array.
			return [
				...currentTodos,
				{
					message: "",
					dueDate: "",
				},
			];
		});
	}

	return (
		<div>
			{todos.map((todo, index) => {
				return (
					<div>
						<input
							type="text"
							placeholder="message"
							value={todo.message}
							onChange={(e) => changeTodoMessage(index, e.target.value)}
						/>
						<input
							type="date"
							value={todo.dueDate}
							onChange={(e) => changeTodoDueDate(index, e.target.value)}
						/>
						<button
							type="button"
							onClick={() => {
								deleteTodo(index);
							}}
						>
							Delete
						</button>
					</div>
				);
			})}
			<button
				type="button"
				onClick={() => {
					addTodo();
				}}
			>
				Add
			</button>
			<pre>state: {JSON.stringify(todos, null, 2)}</pre>
		</div>
	);
}

Client Side RedirectsiOS Deployment