28.1 useState<T>: When TypeScript Can and Cannot Infer the Type
Right, let’s talk about useState and TypeScript. This is where you and the compiler start a beautiful, occasionally pedantic, friendship. The good news is that TypeScript is shockingly good at figuring out what type of state you’re trying to manage. The bad news is that when it can’t, it fails in the most spectacularly unhelpful ways. We’re going to learn how to make it work for us, not against us.
The core principle is simple: TypeScript uses type inference based on the initial value you pass to useState. It looks at that value and says, “Ah, a string. I shall henceforth guard this state variable against any and all non-string interlopers.” It’s a diligent bouncer.
// TypeScript sees the initial value is a string.
// It infers the type as: useState<string>
const [name, setName] = useState('Sarah');
// This is fine. setName expects a string.
setName('Diana');
// This causes a beautiful, immediate compiler error.
// Argument of type 'number' is not assignable to parameter of type 'string'.
setName(42);
When Inference Works Flawlessly
For primitive values, it’s a no-brainer. Strings, numbers, booleans, null—it just works. It also handles complex objects and arrays with ease, inferring the entire structure.
// Inferred as: useState<{ id: number; name: string; active: boolean }>
const [user, setUser] = useState({ id: 1, name: 'Sarah', active: true });
// Inferred as: useState<number[]>
const [scores, setScores] = useState([94, 82, 97]);
You can call setScores with a new array, and TypeScript will ensure every element is a number. It’s like having a dedicated code reviewer who only cares about types and never needs to sleep.
The Classic Pitfall: Empty Initial Values
Here’s where the first “questionable choice” rears its head. What if your initial state is null or an empty array? You intend to fill it later, but TypeScript can only see what’s in front of it right now.
// Inferred type: useState<null>
const [user, setUser] = useState(null);
// Error: Argument of type '{ id: number; }' is not assignable to parameter of type 'null'.
setUser({ id: 1 }); // Nope. Compiler is having none of it.
The same thing happens with empty arrays. TypeScript infers it as never[]—an array that can never have any elements. A truly pessimistic, but technically accurate, interpretation.
// Inferred type: useState<never[]>
const [items, setItems] = useState([]);
// Error: Argument of type 'string' is not assignable to parameter of type 'never'.
setItems(['hello']); // Absolutely not.
Taking Control: Explicitly Defining the Type
When inference fails you, you must step in and be the adult in the room. This is done by passing a type argument to the useState hook. The syntax is the angle brackets <YourType> before the parentheses.
// We tell TypeScript: "This state will be a User object OR null."
const [user, setUser] = useState<User | null>(null);
// Now this works perfectly.
setUser({ id: 1, name: 'Tim' });
// And this is still correctly flagged as an error.
setUser('some string'); // Error
For arrays, you do the same thing. Don’t let it infer never[]; tell it what you mean.
// We explicitly state this will be an array of strings.
const [items, setItems] = useState<string[]>([]);
// Now the compiler is happy.
setItems(['hello', 'world']);
The Lazy Initial State Quirk
You might use a function for lazy initial state to avoid costly calculations on every render: useState(() => expensiveCalculation()). TypeScript’s inference is smart enough to handle this. It looks at the return type of the function.
// Inferred as: useState<number>
const [bigValue, setBigValue] = useState(() => {
const someExpensiveValue = 42 * 1000;
return someExpensiveValue; // The return value is a number, so type is number.
});
However, if your function can return different types, you’ll run into the same empty value problem and need to explicitly define the union type.
Best Practice: Be Specific, But Not Too Specific
Your goal is to provide the minimum viable type. Don’t just slap any on there to shut the compiler up. You’ve just disabled its entire purpose for existing. If your state can be a string or null, use useState<string | null>(null). If it’s an array of a specific object, define that object’s interface.
interface Item {
id: string;
label: string;
}
// Perfect. Clear, concise, and type-safe.
const [list, setList] = useState<Item[]>([]);
This explicit approach turns potential runtime errors (“Why is map of undefined?!”) into compile-time errors, which is the whole point of using TypeScript with React. You’re not just annotating types; you’re formally defining the contract for how that piece of state can be used. And your brilliant friend, the compiler, will hold you to it.