What is Jotai with TypeScript?
Jotai is a minimal, primitive, and flexible state management library for React. Its name comes from the Japanese word meaning "atom state." When paired with TypeScript, Jotai becomes an exceptionally powerful tool for building strongly typed applications where state integrity is enforced at compile time rather than relying solely on runtime checks or developer discipline.
At its core, Jotai treats every piece of state as an independent atom — a small, composable unit that can be read, written, and derived. TypeScript amplifies this model by ensuring that every atom carries explicit type information that propagates through your entire state graph. This means that when you derive new atoms, combine atoms, or pass state through your component tree, TypeScript infers and validates the types automatically, catching mismatches before they ever reach the browser.
Why Strong Typing Matters in Jotai Applications
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Without TypeScript, Jotai atoms are inherently untyped — an atom created with atom(0) is implicitly a number, but nothing prevents you from accidentally setting it to a string elsewhere in your codebase. This can lead to subtle bugs that only surface at runtime. TypeScript transforms Jotai into a type-safe state management system where:
- Type inference automatically determines atom types from initial values
- Compile-time errors catch type mismatches before code is deployed
- IDE autocompletion provides accurate suggestions for atom values and methods
- Refactoring safety ensures that changing a state shape propagates type errors to all consumers
- Self-documenting code makes state structures explicit without additional comments or JSDoc
In large codebases with dozens or hundreds of atoms, these benefits compound dramatically. A type error in a deeply nested derived atom is caught instantly rather than discovered through a cryptic runtime bug report weeks later.
Getting Started: Installing Dependencies
Begin by installing Jotai and its TypeScript type definitions. Jotai ships with first-class TypeScript support out of the box — no additional @types package is needed:
npm install jotai
# or
yarn add jotai
# or
pnpm add jotai
Jotai's package includes its own .d.ts type declarations, so TypeScript integration is immediate. For the best experience, ensure your tsconfig.json has strict: true or at least strictNullChecks: true enabled to catch null/undefined edge cases.
Creating Strongly Typed Primitive Atoms
The simplest way to create a typed atom is to let TypeScript infer the type from the initial value. Jotai's atom() function is generic and automatically captures the type of its argument:
import { atom } from 'jotai';
// TypeScript infers: PrimitiveAtom<number>
const countAtom = atom(0);
// TypeScript infers: PrimitiveAtom<string>
const usernameAtom = atom('guest');
// TypeScript infers: PrimitiveAtom<boolean>
const isLoggedInAtom = atom(false);
// TypeScript infers: PrimitiveAtom<string[]>
const tagsAtom = atom<string[]>([]);
When the initial value doesn't fully capture the intended type — for example, an array that could contain multiple types, or a value that might be null — you can explicitly provide a type parameter to the atom function:
// Explicitly typed atom that can hold null
const selectedUserIdAtom = atom<number | null>(null);
// Explicitly typed atom for a discriminated union
type Status = 'idle' | 'loading' | 'success' | 'error';
const requestStatusAtom = atom<Status>('idle');
// Complex object type
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string | null;
}
const userProfileAtom = atom<UserProfile | null>(null);
The explicit type parameter is particularly valuable when the atom starts as null or undefined but will later hold a complex object. Without it, TypeScript would infer a type of null only, which would prevent assigning the actual value later.
Reading and Writing Typed Atoms in Components
Jotai provides hooks that preserve type safety all the way into your React components. The useAtom hook returns a tuple where both the value and the setter are correctly typed:
import { useAtom } from 'jotai';
import { countAtom, usernameAtom } from './atoms';
function Counter() {
// count is number, setCount is (newValue: number) => void
const [count, setCount] = useAtom(countAtom);
// TypeScript error: Argument of type 'string' is not assignable
// to parameter of type 'number'.
// setCount('five'); // ❌ Compile error!
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
);
}
function UsernameDisplay() {
// username is string, setUsername is (newValue: string) => void
const [username, setUsername] = useAtom(usernameAtom);
return (
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
);
}
For read-only access, use useAtomValue which only returns the current value. For write-only access, use useSetAtom which returns just the setter function. Both are fully typed:
import { useAtomValue, useSetAtom } from 'jotai';
function ReadOnlyCount() {
const count = useAtomValue(countAtom); // number
return <p>{count}</p>;
}
function IncrementButton() {
const setCount = useSetAtom(countAtom); // (update: number | ((prev: number) => number)) => void
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
Derived Atoms with TypeScript Inference
Derived atoms are where TypeScript truly shines with Jotai. When you create a derived atom using atom(get => ...), TypeScript automatically infers the return type from your getter function. This creates a type-safe dependency graph where any change in a source atom's type propagates through all derived atoms:
import { atom } from 'jotai';
const countAtom = atom(0); // PrimitiveAtom<number>
// TypeScript infers: Atom<number> — the return type of the getter
const doubledCountAtom = atom((get) => get(countAtom) * 2);
// TypeScript infers: Atom<string>
const countDisplayAtom = atom((get) => {
const count = get(countAtom);
return `Current count is ${count}`;
});
// TypeScript infers: Atom<boolean>
const isEvenAtom = atom((get) => get(countAtom) % 2 === 0);
When deriving atoms from multiple source atoms, TypeScript correctly infers the intersection of types. The getter function receives a typed get function that preserves each atom's type:
interface Todo {
id: number;
text: string;
completed: boolean;
}
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// TypeScript infers: Atom<Todo[]>
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
The get function inside derived atoms is itself typed — it accepts any atom and returns its precise value type. This means you cannot accidentally read an atom that doesn't exist or use its value in an incompatible way.
Writable Derived Atoms with Type-Safe Setters
Derived atoms can also be writable by providing a setter function as the second argument to atom(). The setter receives typed parameters, and TypeScript ensures the write signature matches the read signature:
const countAtom = atom(0);
// Writable derived atom: read is number, write accepts number
const doubledCountAtom = atom(
(get) => get(countAtom) * 2, // read: () => number
(get, set, newValue: number) => { // write: (number) => void
set(countAtom, newValue / 2);
}
);
// TypeScript error if write parameter type doesn't match read return type:
const brokenAtom = atom(
(get) => get(countAtom) * 2, // returns number
(get, set, newValue: string) => { // ❌ Type mismatch! newValue should be number
set(countAtom, parseInt(newValue));
}
);
For write-only derived atoms where the read type differs from the write type, you can explicitly type the atom. This is useful for action atoms that accept parameters but return a different type:
const todosAtom = atom<Todo[]>([]);
// Atom that reads Todo[] but writes a single Todo for appending
const addTodoAtom = atom<Todo[], [Todo]>(
(get) => get(todosAtom), // read: () => Todo[]
(get, set, newTodo: Todo) => { // write: (todo: Todo) => void
set(todosAtom, [...get(todosAtom), newTodo]);
}
);
// Using the atom
function AddTodoButton() {
const addTodo = useSetAtom(addTodoAtom); // (newTodo: Todo) => void
const handleClick = () => {
addTodo({
id: Date.now(),
text: 'New todo',
completed: false
});
};
return <button onClick={handleClick}>Add Todo</button>;
}
Async Atoms and TypeScript
Jotai supports async atoms natively. When a derived atom's getter returns a Promise, Jotai automatically handles the async resolution. TypeScript infers the resolved type, not the Promise wrapper:
const userIdAtom = atom<number>(1);
// Async derived atom — TypeScript infers: Atom<UserProfile>
// (the resolved type, not Promise<UserProfile>)
const userProfileAsyncAtom = atom(async (get) => {
const userId = get(userIdAtom);
const response = await fetch(`https://api.example.com/users/${userId}`);
const data: UserProfile = await response.json();
return data;
});
// In components, use with Suspense or loadable API
function UserProfileDisplay() {
const userProfile = useAtomValue(userProfileAsyncAtom);
// userProfile is typed as UserProfile, not Promise<UserProfile>
return <div>{userProfile.name}</div>;
}
For more granular control over async state, Jotai provides utilities like loadable and unwrap that maintain type safety. The loadable utility wraps an async atom's value in a discriminated union:
import { loadable } from 'jotai/utils';
// loadable wraps the async value in a typed Loadable<UserProfile>
const loadableUserProfileAtom = loadable(userProfileAsyncAtom);
function UserProfileWithStatus() {
const loadableProfile = useAtomValue(loadableUserProfileAtom);
// TypeScript narrows the discriminated union:
if (loadableProfile.state === 'loading') {
return <p>Loading...</p>;
}
if (loadableProfile.state === 'hasError') {
// loadableProfile.error is typed as unknown (or Error in newer versions)
return <p>Error: {String(loadableProfile.error)}</p>;
}
// loadableProfile.data is UserProfile
return <p>Welcome, {loadableProfile.data.name}</p>;
}
Type-Safe Atom Families
Atom families allow you to create parameterized atoms dynamically. With TypeScript, you can constrain the parameter type and ensure each atom in the family carries the correct value type:
import { atomFamily } from 'jotai/utils';
interface TodoItem {
id: string;
text: string;
completed: boolean;
}
// atomFamily with typed parameter and value
const todoAtomFamily = atomFamily((id: string) =>
atom<TodoItem | null>(null)
);
// TypeScript enforces string parameter
const todoAtom = todoAtomFamily('todo-123'); // OK
// const invalidAtom = todoAtomFamily(456); // ❌ Error: number not assignable to string
// Parameterized with complex keys
type NotificationKey = {
userId: number;
notificationId: string;
};
const notificationAtomFamily = atomFamily(
(key: NotificationKey) => atom<{ read: boolean; timestamp: number }>({ read: false, timestamp: 0 }),
(a: NotificationKey, b: NotificationKey) => a.userId === b.userId && a.notificationId === b.notificationId
);
Strongly Typed Atom Composition Patterns
Pattern 1: Splitting Large State Objects
Instead of one monolithic atom, split state into focused atoms. TypeScript ensures each split atom maintains its own type contract:
interface AppState {
user: UserProfile | null;
todos: Todo[];
filter: 'all' | 'active' | 'completed';
ui: {
sidebarOpen: boolean;
theme: 'light' | 'dark';
};
}
// ❌ Avoid: single large atom
const appStateAtom = atom<AppState>({
user: null,
todos: [],
filter: 'all',
ui: { sidebarOpen: false, theme: 'light' }
});
// ✅ Prefer: focused atoms with precise types
const userAtom = atom<UserProfile | null>(null);
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
const sidebarOpenAtom = atom(false);
const themeAtom = atom<'light' | 'dark'>('light');
Pattern 2: Selector Atoms for Derived Computations
Create selector-style atoms that derive specific pieces of state. TypeScript infers the exact return type, making selectors self-documenting:
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// Selector: number of completed todos
const completedCountAtom = atom((get) =>
get(todosAtom).filter(t => t.completed).length
); // Atom<number>
// Selector: whether all todos are completed
const allCompletedAtom = atom((get) =>
get(todosAtom).length > 0 && get(todosAtom).every(t => t.completed)
); // Atom<boolean>
// Selector: todos with applied filter
const visibleTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
return filter === 'completed' ? todos.filter(t => t.completed)
: filter === 'active' ? todos.filter(t => !t.completed)
: todos;
}); // Atom<Todo[]>
Pattern 3: Action Atoms with Payload Types
Encapsulate complex state updates in write-only atoms that accept typed payloads:
const todosAtom = atom<Todo[]>([]);
// Action: toggle a todo by id
const toggleTodoAtom = atom<null, [number]>(
null, // read value not used
(get, set, todoId: number) => {
const todos = get(todosAtom);
set(
todosAtom,
todos.map(t => t.id === todoId ? { ...t, completed: !t.completed } : t)
);
}
);
// Action: update todo text
const updateTodoTextAtom = atom<null, [number, string]>(
null,
(get, set, todoId: number, newText: string) => {
set(
todosAtom,
get(todosAtom).map(t => t.id === todoId ? { ...t, text: newText } : t)
);
}
);
// Action: batch remove todos by ids
const removeTodosAtom = atom<null, [number[]]>(
null,
(get, set, todoIds: number[]) => {
set(
todosAtom,
get(todosAtom).filter(t => !todoIds.includes(t.id))
);
}
);
function TodoItem({ id, text, completed }: Todo) {
const toggleTodo = useSetAtom(toggleTodoAtom);
const updateText = useSetAtom(updateTodoTextAtom);
return (
<div>
<input
type="checkbox"
checked={completed}
onChange={() => toggleTodo(id)} // id must be number ✅
/>
<input
value={text}
onChange={(e) => updateText(id, e.target.value)} // id: number, text: string ✅
/>
</div>
);
}
Advanced TypeScript Patterns with Jotai
Discriminated Union State Machines
Use discriminated unions to model finite state machines with Jotai. TypeScript's narrowing guarantees that you handle every state correctly:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading'; progress?: number }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
interface GitHubUser {
login: string;
avatar_url: string;
name: string;
}
const gitHubUserStateAtom = atom<RequestState<GitHubUser>>({ status: 'idle' });
// Async action to fetch user
const fetchGitHubUserAtom = atom<null, [string]>(
null,
async (get, set, username: string) => {
set(gitHubUserStateAtom, { status: 'loading', progress: 0 });
try {
const response = await fetch(`https://api.github.com/users/${username}`);
const data: GitHubUser = await response.json();
set(gitHubUserStateAtom, { status: 'success', data });
} catch (err) {
set(gitHubUserStateAtom, {
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error'
});
}
}
);
function GitHubUserCard() {
const [state] = useAtom(gitHubUserStateAtom);
const fetchUser = useSetAtom(fetchGitHubUserAtom);
// TypeScript narrows the discriminated union automatically
switch (state.status) {
case 'idle':
return <button onClick={() => fetchUser('octocat')}>Load User</button>;
case 'loading':
return <p>Loading{state.progress !== undefined ? ` (${state.progress}%)` : ''}...</p>;
case 'success':
return <div><img src={state.data.avatar_url} /><p>{state.data.name}</p></div>;
case 'error':
return <p>Error: {state.error}</p>;
}
}
Generic Atom Utilities
Create reusable atom factories with generic type parameters. TypeScript preserves the type parameter throughout the atom's lifecycle:
// Generic atom factory for a resettable counter
function createResettableCounterAtom(initialValue: number) {
const baseAtom = atom(initialValue);
const resetAtom = atom(null, (get, set) => {
set(baseAtom, initialValue);
});
return { baseAtom, resetAtom } as const;
}
// Generic atom factory for a list with selection
function createSelectionListAtom<T>() {
const itemsAtom = atom<T[]>([]);
const selectedIndexAtom = atom<number | null>(null);
const selectedItemAtom = atom((get) => {
const index = get(selectedIndexAtom);
if (index === null) return null;
return get(itemsAtom)[index] ?? null;
}); // Atom<T | null>
return { itemsAtom, selectedIndexAtom, selectedItemAtom };
}
// Usage with concrete type
interface Product {
sku: string;
name: string;
price: number;
}
const productList = createSelectionListAtom<Product>();
// productList.selectedItemAtom is Atom<Product | null> ✅
Atom Effects with Type-Safe Cleanup
Jotai's atomEffect (from jotai-effect) allows reactive side effects. TypeScript ensures the effect function receives typed atoms:
import { atomEffect } from 'jotai-effect';
const countAtom = atom(0);
const messageAtom = atom<string>('');
// atomEffect infers types from the atoms it references
const persistenceEffect = atomEffect((get, set) => {
const count = get(countAtom); // number
// TypeScript error: 'count' is number, not assignable to string
// set(messageAtom, count); // ❌
// Correct usage
set(messageAtom, `Count is ${count}`); // ✅
// Optional cleanup function
return () => {
console.log('Effect cleaned up');
};
});
Best Practices for Jotai TypeScript Applications
1. Define Interface Types Before Atoms
Always define interfaces or type aliases for your state shapes before creating atoms. This ensures consistency across your codebase and makes refactoring safer:
// ✅ Good: type first, then atom
interface ShoppingCartItem {
productId: string;
quantity: number;
variant: { size: string; color: string };
}
const cartItemsAtom = atom<ShoppingCartItem[]>([]);
// ❌ Avoid: inline complex types in atom declarations
const badAtom = atom<Array<{ productId: string; quantity: number; variant: { size: string; color: string } }>>([]);
2. Use Readonly Atoms for Exposed State
When a component should only read state but never modify it directly, derive a read-only atom or export only useAtomValue:
// atoms.ts
const _cartItemsAtom = atom<CartItem[]>([]);
// Public: read-only derived atom
export const cartItemsAtom = atom((get) => get(_cartItemsAtom));
// Public: action atoms for mutations
export const addToCartAtom = atom<null, [CartItem]>(
null,
(get, set, item) => set(_cartItemsAtom, [...get(_cartItemsAtom), item])
);
3. Leverage TypeScript's const Assertions for Constants
Use as const to narrow literal types and prevent accidental widening:
// Without as const: filterOptions is string[]
const filterOptions = ['all', 'active', 'completed']; // type: string[]
// With as const: filterOptions is readonly ['all', 'active', 'completed']
const filterOptionsConst = ['all', 'active', 'completed'] as const;
type Filter = typeof filterOptionsConst[number]; // 'all' | 'active' | 'completed'
const filterAtom = atom<Filter>('all');
4. Keep Atoms Small and Focused
Large atoms with many properties are harder to type-check effectively. Split them into smaller, single-purpose atoms. TypeScript will track dependencies more precisely and provide better error messages:
// ❌ Monolithic
const formStateAtom = atom({
username: '',
email: '',
password: '',
confirmPassword: '',
errors: {} as Record<string, string>,
isSubmitting: false,
isDirty: false
});
// ✅ Focused atoms
const usernameAtom = atom('');
const emailAtom = atom('');
const passwordAtom = atom('');
const confirmPasswordAtom = atom('');
const formErrorsAtom = atom<Record<string, string>>({});
const isSubmittingAtom = atom(false);
const isDirtyAtom = atom(false);
5. Use Type Guards with Async Atoms
When working with async atoms that fetch external data, validate the shape at runtime with TypeScript type guards to ensure the data matches your expected types:
interface ApiUser {
id: number;
name: string;
email: string;
}
function isApiUser(data: unknown): data is ApiUser {
return (
typeof data === 'object' &&
data !== null &&
'id' in data && typeof data.id === 'number' &&
'name' in data && typeof data.name === 'string' &&
'email' in data && typeof data.email === 'string'
);
}
const userAtom = atom(async (get) => {
const response = await fetch('/api/user');
const json: unknown = await response.json();
if (!isApiUser(json)) {
throw new Error('Invalid user data received from API');
}
return json; // Now typed as ApiUser ✅
});
6. Explicitly Type Atom Setters for Clarity
When creating writable derived atoms with complex setter logic, explicitly annotate the setter parameter types rather than relying solely on inference. This serves as documentation and catches regressions:
const todosAtom = atom<Todo[]>([]);
const updateTodoAtom = atom(
(get) => get(todosAtom),
(get, set, update: { id: number; changes: Partial<Omit<Todo, 'id'>> }) => {
const todos = get(todosAtom);
set(
todosAtom,
todos.map(t => t.id === update.id ? { ...t, ...update.changes } : t)
);
}
);
7. Organize Atoms by Domain with Barrel Exports
Structure your atoms in domain-specific files and use barrel exports. This keeps TypeScript module resolution fast and provides clear import paths:
// atoms/user.atoms.ts
export const userProfileAtom = atom<UserProfile | null>(null);
export const isAuthenticatedAtom = atom((get) => get(userProfileAtom) !== null);
// atoms/todos.atoms.ts
export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// atoms/index.ts (barrel)
export * from './user.atoms';
export * from './todos.atoms';
Common TypeScript Pitfalls and Solutions
Pitfall 1: Widening Initial Values
TypeScript may widen literal types when inferring atom types. An atom initialized with atom('idle') is inferred as PrimitiveAtom<string>, not PrimitiveAtom<'idle'>. Always explicitly annotate with union types when you need narrow literals:
// ❌ Widened to string
const statusAtom = atom('idle'); // PrimitiveAtom<string>
// ✅ Explicit union preserves narrow types
const statusAtomFixed = atom<'idle' | 'loading' | 'success' | 'error'>('idle');
Pitfall 2: Async Atom Error Handling
Async atoms that throw reject the promise, but TypeScript cannot infer the error type. Wrap async operations with proper error boundaries and use the loadable utility to handle errors gracefully:
// Async atom that may throw
const dataAtom = atom(async () => {
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<MyDataType>;
});
// Use with error boundary or loadable
const loadableDataAtom = loadable(dataAtom);
Pitfall 3: Mutating Objects in Place
Jotai relies on immutability to detect changes. TypeScript won't prevent you from mutating an object in place, but Jotai won't re-render subscribers. Always create new objects:
const settingsAtom = atom({ theme: 'light', fontSize: 14 });
function SettingsPanel() {
const [settings, setSettings] = useAtom(settingsAtom);
const updateTheme = (theme: 'light' | 'dark') => {
// ❌ Mutation — won't trigger re-render
// settings.theme = theme;
// setSettings(settings);
// ✅ New object — triggers re-render
setSettings({ ...settings, theme });
};
return <div>...</div>;
}
Conclusion
Jotai and TypeScript together form a remarkably cohesive state management solution. Jotai's atomic model naturally aligns with TypeScript's type system — each atom is a typed container, derivations produce inferred types, and the entire state graph benefits from compile-time verification. By following the patterns and best practices outlined in this tutorial, you can build React applications where state-related bugs are caught at the type level rather than surfacing as runtime errors. The combination of Jotai's minimal API surface and TypeScript's rigorous type checking yields code that is not only safer but also more maintainable, self-documenting, and resilient to refactoring. Whether you're building a small feature or a complex enterprise application, typing your Jotai atoms thoughtfully will pay dividends throughout the lifetime of your codebase.