Managing state in React becomes more predictable and error resistant when you combine the power of TypeScript’s type system with immutability features like readonly. In this post, we’ll explore how to strongly type your state with useState(), enforce immutability using readonly, and even go deeper with ReadonlyDeep for complex structures. These patterns not only improve IntelliSense and prevent bugs but also align perfectly with React’s rendering philosophy.
🧠 useState<Type>()
in TypeScript
- Strongly types state variables for better safety and IntelliSense.
- Prevents accidental type mismatches during state updates.
- Encourages predictable and maintainable state management.
const [count, setCount] = useState<number>(0); // Strongly typed as number
🔒 readonly
in TypeScript
- Enforces immutability on objects and arrays.
- Prevents reassignment or mutation of properties.
- Useful for protecting state structures from unintended changes.
Individual readonly on each property (manual)
type User = {
readonly id: number;
readonly name: string;
};
const [user, setUser] = useState<User>({
id: 1,
name: 'Arka',
});
// user.id = 2; ❌ Error: Cannot assign to 'id' because it is a read-only property
Using Readonly directly (automatic)
type User = {
id: number;
name: string;
};
const [user, setUser] = useState<Readonly<User>>({
id: 1,
name: 'Arka',
});
// user.id = 2; ❌ Error: Cannot assign to 'id' because it is a read-only property
Updating State Safely
const updateName = (newName: string) => {
setUser({ ...user, name: newName }); // ✅ Immutable update
};
📦 readonly
vs ReadonlyDeep
-
readonly
only applies to top-level properties. - For deeply nested immutability, use utility types like
ReadonlyDeep<T>
(via libraries or custom types). - React state usually benefits from shallow immutability, but deep immutability can help in complex structures.
Install type-fest
npm install type-fest
React + TypeScript Example with ReadonlyDeep
import { useState } from 'react';
import type { ReadonlyDeep } from 'type-fest';
type Address = {
city: string;
zip: number;
};
type User = {
id: number;
name: string;
address: Address;
};
function Profile() {
const [user, setUser] = useState<ReadonlyDeep<User>>({
id: 1,
name: 'Arka',
address: {
city: 'Bangalore',
zip: 560001,
},
});
// ❌ These will all error due to deep readonly
// user.name = 'New Name';
// user.address.city = 'Mumbai';
const updateCity = (newCity: string) => {
setUser({
...user,
address: {
...user.address,
city: newCity,
},
});
};
return (
<div>
<p>{user.name} lives in {user.address.city}</p>
<button onClick={() => updateCity('Mumbai')}>Move to Mumbai</button>
</div>
);
}
⚠️ Why readonly
is useful for objects and arrays
- Makes entire objects or arrays immutable, not just single keys.
- Prevents direct mutation, which can break React’s render cycle.
- Especially important when passing props or managing shared state.
Why readonly is useful for objects and arrays
const numbers: readonly number[] = [1, 2, 3];
// numbers.push(4); ❌ Error: push does not exist on readonly array
🚫 Avoid Direct Mutation of State
- Always use the setter function from
useState
. - Direct mutation can lead to stale renders or unexpected behavior.
-
readonly
helps enforce this pattern and catch errors early.
import { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState<string[]>(['Learn TS']);
const addTodo = (todo: string) => {
// ❌ Direct mutation (bad)
// todos.push(todo);
// ✅ Correct way
setTodos([...todos, todo]);
};
return <button onClick={() => addTodo('Write blog')}>Add Todo</button>;
}
🧩 readonly
Arrays in React
- Prevents use of mutating methods like
push
,pop
,splice
, etc. - Encourages use of non-mutating patterns like
map
,filter
,concat
. - Aligns with React’s expectation of immutability for re-renders.
✅ Best Practices
- Combine
useState
with proper typing for safer updates. - Use
readonly
in types to protect data structures. - Avoid mutation to maintain React’s predictable rendering behavior.
Top comments (0)