This article is published in English.
Typing React Hooks: useState, useEffect, useReducer, and Custom Hooks
Learn how to correctly type useState, useEffect, useReducer, and custom hooks in TypeScript, plus when choosing TypeScript over plain JavaScript actually pays off.
Type errors caught at compile time save you from debugging sessions that could otherwise stretch late into the night, and this is exactly why teams pairing React with TypeScript treat typed hooks as a baseline rather than a luxury. Getting comfortable with how to type state, effects, reducers, and custom hooks — and knowing when reaching for TypeScript is even the right call in the first place — are two sides of the same practical skill set for building reliable React applications.
Why Type Safety Belongs in Your Hooks
React Hooks already gave developers a cleaner, more composable way to structure components. TypeScript adds a safety layer on top of that structure, so that mistakes surface while you're writing the code instead of after a user stumbles into a broken screen in production.
Hooks were designed from the start to make reusable logic predictable — a point Dan Abramov has made when discussing React's design philosophy. TypeScript's contribution is to take that predictability and make it explicit and checkable, rather than something you have to hold in your head.
Typing useState Correctly
In most everyday cases, TypeScript figures out your state type on its own without any help:
// inferred as boolean, no annotation needed
const [isLoading, setIsLoading] = useState(false);
Things change when your initial value starts out as null or undefined — in that case, you need to annotate the type yourself instead of relying on inference:
interface UserProfile {
id: string;
name: string;
avatarUrl: string;
}
const [user, setUser] = useState<UserProfile | null>(null);
A useful habit here is to resist the urge to fall back on a generic type just to make an error message disappear. Doing that quietly removes the entire benefit you were trying to get from TypeScript in the first place.
Typing useEffect: Less Complicated Than Expected
Generics don't really apply to useEffect, but you still need to be careful with dependency arrays and cleanup logic:
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data: UserProfile = await res.json();
setUser(data);
};
fetchUser();
return () => controller.abort(); // cleanup on unmount
}, [userId]);
Typing useReducer for More Complex State
This is the hook where TypeScript's value becomes obvious:
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'CLEAR_CART' };
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'ADD_ITEM':
return [...state, action.payload];
case 'REMOVE_ITEM':
return state.filter((item) => item.id !== action.payload);
case 'CLEAR_CART':
return [];
default:
return state;
}
}
With action types modeled this way, dispatching something invalid becomes a compile-time error you catch immediately, instead of a runtime surprise you find out about later.
Writing Reusable Custom Hooks with Generics
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Because this hook is generic, it can be reused anywhere in your codebase — with strings, objects, or arrays — while keeping full type accuracy for whatever data you pass through it.
The Short Version
- Let TypeScript infer simple state on its own, but be explicit whenever a value can be null or undefined.
- Model your useReducer actions as a discriminated union.
- Use generics in custom hooks to keep them reusable across different data types.
- Treat
anyas a shortcut that quietly costs you more than it saves later on.
Choosing Between Plain JavaScript and TypeScript
Once you understand how much safety typed hooks give you, a broader question naturally comes up: should every project actually use TypeScript, or is that sometimes over-engineering? For a long time, this was framed as a binary choice, and picking either side meant living with real tradeoffs.
In the past, opting for TypeScript meant wrestling with build pipelines, tools like ts-node, and a pile of configuration files just to get a script running. Now that runtimes such as Node.js support native type stripping, that friction has largely disappeared, and the boundary between writing plain JavaScript and writing TypeScript is much thinner than it used to be.
Looking at how each option affects your everyday workflow makes it easier to decide which one actually fits the project in front of you.
Why Plain JavaScript Still Has a Place
JavaScript is the language the browser and Node.js understand natively. You write a file, run it, and it executes immediately, with no compiler standing in the way and nothing extra to declare.
- Instant prototyping: when you're testing an idea, throwing together a quick script, or spinning up a small MVP, JavaScript lets you move as fast as you can think.
- No setup required: there's no need for a
tsconfig.jsonor a type-checking step just to confirm a function does what you expect. - Less mental load: your attention stays on the actual logic of the program instead of on type declarations or compiler complaints.
The downside shows up once a JavaScript codebase grows past a few thousand lines. At that point, refactoring turns risky — renaming a property across twenty files means relying on a global find-and-replace and hoping nothing silently breaks when the app runs.
Why TypeScript Pays Off as Projects Grow
TypeScript layers a static type system on top of JavaScript, functioning like an automated reviewer that flags problems as you type — for instance, warning you the moment you try to pass a string into a function that expects a number.
- Documentation that stays accurate: types double as living documentation. An interface tells you the exact shape an object must have without forcing you to trace through several files of implementation.
- Safer refactors: if you change a data model or a database schema, the compiler points to every location in the codebase that now needs an update.
- Better editor support: tools like VS Code or Cursor rely on TypeScript's language server to provide fast autocomplete and inline error highlighting as you work.
The tradeoff historically was a real "tooling tax" — configuring compilers, source maps, and wrapper scripts added extra steps to what used to be a simple workflow.
Why That Tradeoff No Longer Applies the Same Way
The old complaint that "TypeScript setup is too much hassle" doesn't hold up as strongly anymore. Current versions of Node.js can run TypeScript files directly, without a separate build step, thanks to type stripping.
Behind the scenes, the runtime just removes your type annotations and interface declarations, leaving plain JavaScript to run right away. That means you keep the development-time safety of static types without the setup overhead that used to come with it.
Matching the Tool to the Job
For a disposable script, a small automation task, or a project meant purely for learning how the web works, plain JavaScript remains the better fit. Skipping the extra structure keeps the process lightweight and enjoyable.
For nearly everything else — team codebases, applications with several interacting data models, or any project you expect to maintain beyond a month — the small amount of setup TypeScript requires is worth it. Since modern runtimes now handle execution smoothly either way, you don't really have to choose between flexibility and safety: JavaScript's simplicity and TypeScript's guardrails are both available, and picking one is mostly about matching it to how long-lived and collaborative the project is going to be.