How to type state in React with TypeScript
Managing state in React becomes significantly more predictable and less error-prone when you leverage TypeScript to define the shape of your data.
As the creator of CoreUI, I have spent years building enterprise-grade React components and templates where robust typing is a non-negotiable standard.
With 25 years of experience in software development, I can tell you that “undefined is not a function” is a ghost of the past if you type your state correctly.
The most efficient approach involves using TypeScript’s type inference for primitives and explicit generics for complex objects or union types.
Use the generic parameter in the useState hook to explicitly define the type of your state when inference is not enough.
interface User {
id: string
name: string
}
const [user, setUser] = useState<User | null>(null)
In this example, the useState hook is told that the state can either be of type User or null. This ensures that whenever you access user, TypeScript will force you to check if it exists, preventing runtime crashes.
1. Leveraging Type Inference for Primitives
For simple types like strings, numbers, and booleans, TypeScript is smart enough to infer the type based on the initial value provided to useState. This keeps your code clean and avoids redundant declarations.
import React, { useState } from 'react'
const Counter: React.FC = () => {
// TypeScript infers 'count' is a number
const [count, setCount] = useState(0)
// TypeScript infers 'isActive' is a boolean
const [isActive, setIsActive] = useState(false)
// TypeScript infers 'name' is a string
const [name, setName] = useState('CoreUI User')
const increment = () => {
// Correct usage
setCount(prev => prev + 1)
// This would trigger a TypeScript error:
// setCount('1')
}
return (
<div>
<p>{name} has clicked {count} times.</p>
<button onClick={increment}>Add</button>
</div>
)
}
export default Counter
In this scenario, adding <number> to useState is unnecessary because the initial value 0 already establishes the type. This is the same minimalist approach we follow in CoreUI to keep the codebase maintainable.
2. Typing Objects with Interfaces
When dealing with complex data structures, such as a user profile or a configuration object, you should define an interface. This provides a clear contract for what the state should contain.
import React, { useState } from 'react'
interface UserProfile {
id: string
username: string
email: string
isAdmin: boolean
}
const UserSettings: React.FC = () => {
// Explicitly typing the state with the UserProfile interface
const [profile, setProfile] = useState<UserProfile>({
id: '1',
username: 'admin',
email: '[email protected]',
isAdmin: true
})
const updateEmail = (newEmail: string) => {
setProfile(prev => ({
...prev,
email: newEmail
}))
}
return (
<div>
<h1>Settings for {profile.username}</h1>
<input
value={profile.email}
onChange={(e) => updateEmail(e.target.value)}
/>
</div>
)
}
By using the <UserProfile> generic, TypeScript will alert you if you forget a required field or if you try to assign a value of the wrong type during a state update.
3. Handling Nullable State
Often, state starts as null or undefined while data is being fetched from an API. You must account for this in your type definition to ensure your UI handles the loading state safely.
import React, { useState, useEffect } from 'react'
interface Post {
id: number
title: string
body: string
}
const PostViewer: React.FC = () => {
// State can be a Post object or null
const [post, setPost] = useState<Post | null>(null)
useEffect(() => {
const fetchPost = async () => {
const response = await fetch('https://api.example.com/posts/1')
const data: Post = await response.json()
setPost(data)
}
fetchPost()
}, [])
// TypeScript forces us to handle the null case
if (!post) {
return <div>Loading post...</div>
}
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
)
}
This pattern is essential for stability. If you didn’t include | null, TypeScript might assume the object is always present, leading to errors when trying to access post.title before the fetch completes.
4. Typing Arrays of Objects
Arrays are common in state, whether you are listing users or messages. You can type an array of objects by appending [] to the type name inside the generic.
import React, { useState } from 'react'
interface TodoItem {
id: number
text: string
completed: boolean
}
const TodoList: React.FC = () => {
// State is an array of TodoItem objects
const [todos, setTodos] = useState<TodoItem[]>([])
const addTodo = (text: string) => {
const newTodo: TodoItem = {
id: Date.now(),
text,
completed: false
}
setTodos(prev => [...prev, newTodo])
}
const removeTodo = (id: number) => {
// We can safely use array methods
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
)
}
When managing lists, you might need to perform operations like filtering an array in JavaScript or sorting an array in JavaScript before updating the state. TypeScript ensures these operations return the expected type.
5. Using Discriminated Unions for UI State
For complex UI logic, such as a button that can be in multiple states (loading, success, error), using a union of string literals is much cleaner than multiple booleans.
import React, { useState } from 'react'
type RequestStatus = 'idle' | 'loading' | 'success' | 'error'
const SubmitButton: React.FC = () => {
const [status, setStatus] = useState<RequestStatus>('idle')
const handleSubmit = async () => {
setStatus('loading')
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000))
setStatus('success')
} catch {
setStatus('error')
}
}
return (
<div>
<button
disabled={status === 'loading'}
onClick={handleSubmit}
>
{status === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{status === 'error' && <p style={{ color: 'red' }}>Submission failed.</p>}
</div>
)
}
This approach prevents “impossible states,” where you might accidentally have isLoading and isSuccess both set to true.
6. Integration with CoreUI Components
When using professional component libraries like CoreUI, typing state becomes even more important to ensure that component props receive the correct data types.
import React, { useState } from 'react'
import { CFormInput, CButton, CAlert } from '@coreui/react'
const CoreUIForm: React.FC = () => {
const [inputValue, setInputValue] = useState<string>('')
const [submitted, setSubmitted] = useState<boolean>(false)
const handleAction = () => {
if (inputValue.length > 0) {
setSubmitted(true)
}
}
return (
<div className='p-4'>
<CFormInput
type='text'
placeholder='Enter your name'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<CButton
color='primary'
className='mt-3'
onClick={handleAction}
>
Submit
</CButton>
{submitted && (
<CAlert color='success' className='mt-3'>
Hello, {inputValue}! Your data was saved.
</CAlert>
)}
</div>
)
}
In this example, we use the CFormInput and CButton components. By typing our state as a string, we ensure compatibility with the value prop of the input component.
Best Practice Note:
Always prefer type inference where possible to keep your code concise. Only use explicit generics (useState<Type>) when the initial value is null, an empty array, or when the state involves a complex union type. This is a core philosophy we use when developing React Dashboard Templates at CoreUI to ensure maximum developer productivity and code clarity. Once you have your state typed, the natural next step is to learn how to type props in React with TypeScript so your components communicate safely.



