Creating new todos is where your app starts to feel interactive. Users type, hit enter, and boom—their todo appears. But behind that simple interaction lies a complex dance of form handling, API calls, state updates, and error recovery. TypeScript helps us choreograph this dance perfectly, ensuring type safety from the input field all the way to the server response.
In this tutorial, we’ll build robust todo creation with proper TypeScript types, optimistic updates for snappy UX, and graceful error handling.
The Todo Creation Flow
Here’s what happens when a user creates a todo:
- User types in a form and submits
- We validate the input
- Send a POST request to the API
- Handle the response (success or failure)
- Update local state accordingly
- Provide feedback to the user
Let’s build this step by step.
Basic POST Request Pattern
First, let’s define our types and basic creation flow:
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
// What we send to create a todo (id is generated by server)
interface CreateTodoDTO {
userId: number;
title: string;
completed: boolean;
}
function TodoCreator() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodoTitle, setNewTodoTitle] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const createTodo = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!newTodoTitle.trim()) {
setError('Please enter a todo title');
return;
}
setIsCreating(true);
setError(null);
const newTodo: CreateTodoDTO = {
userId: 1, // In a real app, this would be the current user's ID
title: newTodoTitle.trim(),
completed: false,
};
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTodo),
});
if (!response.ok) {
throw new Error(`Failed to create todo: ${response.status}`);
}
const createdTodo: Todo = await response.json();
// Add the new todo to our local state
setTodos((prevTodos) => [...prevTodos, createdTodo]);
setNewTodoTitle(''); // Clear the input
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create todo');
} finally {
setIsCreating(false);
}
};
return (
<div>
<form onSubmit={createTodo}>
<input
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
disabled={isCreating}
/>
<button type="submit" disabled={isCreating || !newTodoTitle.trim()}>
{isCreating ? 'Creating...' : 'Add Todo'}
</button>
</form>
{error && <div className="error-message">{error}</div>}
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}Optimistic Creation
For better UX, show the todo immediately while the request is in flight:
interface OptimisticTodo extends Todo {
isOptimistic?: boolean;
failedToCreate?: boolean;
}
function OptimisticTodoCreator() {
const [todos, setTodos] = useState<OptimisticTodo[]>([]);
const [newTodoTitle, setNewTodoTitle] = useState('');
const createTodoOptimistically = async (e: React.FormEvent) => {
e.preventDefault();
const title = newTodoTitle.trim();
if (!title) return;
// Generate a temporary ID (negative to distinguish from real IDs)
const tempId = -Date.now();
const optimisticTodo: OptimisticTodo = {
id: tempId,
userId: 1,
title,
completed: false,
isOptimistic: true,
};
// Add optimistic todo immediately
setTodos((prev) => [...prev, optimisticTodo]);
setNewTodoTitle('');
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 1,
title,
completed: false,
}),
});
if (!response.ok) {
throw new Error('Failed to create todo');
}
const createdTodo: Todo = await response.json();
// Replace optimistic todo with real one
setTodos((prev) =>
prev.map((todo) => (todo.id === tempId ? { ...createdTodo, isOptimistic: false } : todo)),
);
} catch (error) {
// Mark the optimistic todo as failed
setTodos((prev) =>
prev.map((todo) =>
todo.id === tempId ? { ...todo, isOptimistic: false, failedToCreate: true } : todo,
),
);
// Optionally remove it after a delay
setTimeout(() => {
setTodos((prev) => prev.filter((todo) => todo.id !== tempId));
}, 3000);
}
};
const retryFailedTodo = async (todo: OptimisticTodo) => {
// Reset the failed state
setTodos((prev) =>
prev.map((t) => (t.id === todo.id ? { ...t, failedToCreate: false, isOptimistic: true } : t)),
);
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: todo.userId,
title: todo.title,
completed: todo.completed,
}),
});
if (!response.ok) throw new Error('Retry failed');
const createdTodo: Todo = await response.json();
// Replace with real todo
setTodos((prev) => prev.map((t) => (t.id === todo.id ? createdTodo : t)));
} catch (error) {
// Mark as failed again
setTodos((prev) =>
prev.map((t) =>
t.id === todo.id ? { ...t, isOptimistic: false, failedToCreate: true } : t,
),
);
}
};
return (
<div>
<form onSubmit={createTodoOptimistically}>
<input
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
style={{
opacity: todo.isOptimistic ? 0.6 : 1,
color: todo.failedToCreate ? 'red' : 'inherit',
}}
>
{todo.title}
{todo.isOptimistic && <span> (Saving...)</span>}
{todo.failedToCreate && (
<>
<span> (Failed)</span>
<button onClick={() => retryFailedTodo(todo)}>Retry</button>
</>
)}
</li>
))}
</ul>
</div>
);
}Form Validation with TypeScript
Let’s add proper validation before sending to the server:
// Validation errors by field
interface ValidationErrors {
title?: string;
priority?: string;
dueDate?: string;
}
// Extended todo with additional fields
interface ExtendedTodo extends Todo {
priority: 'low' | 'medium' | 'high';
dueDate?: string;
tags?: string[];
}
interface CreateExtendedTodoDTO {
userId: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
dueDate?: string;
tags?: string[];
}
function validateTodoForm(data: Partial<CreateExtendedTodoDTO>): ValidationErrors {
const errors: ValidationErrors = {};
if (!data.title?.trim()) {
errors.title = 'Title is required';
} else if (data.title.length < 3) {
errors.title = 'Title must be at least 3 characters';
} else if (data.title.length > 100) {
errors.title = 'Title must be less than 100 characters';
}
if (data.dueDate) {
const date = new Date(data.dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (isNaN(date.getTime())) {
errors.dueDate = 'Invalid date format';
} else if (date < today) {
errors.dueDate = 'Due date cannot be in the past';
}
}
return errors;
}
function ValidatedTodoForm() {
const [formData, setFormData] = useState<Partial<CreateExtendedTodoDTO>>({
userId: 1,
title: '',
completed: false,
priority: 'medium',
dueDate: '',
tags: [],
});
const [errors, setErrors] = useState<ValidationErrors>({});
const [touched, setTouched] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (field: keyof CreateExtendedTodoDTO, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field as keyof ValidationErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleBlur = (field: string) => {
setTouched((prev) => new Set([...prev, field]));
// Validate on blur
const fieldErrors = validateTodoForm(formData);
if (fieldErrors[field as keyof ValidationErrors]) {
setErrors((prev) => ({
...prev,
[field]: fieldErrors[field as keyof ValidationErrors],
}));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const validationErrors = validateTodoForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
// Mark all fields as touched
setTouched(new Set(Object.keys(formData)));
return;
}
setIsSubmitting(true);
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error('Failed to create todo');
const created = await response.json();
console.log('Created todo:', created);
// Reset form
setFormData({
userId: 1,
title: '',
completed: false,
priority: 'medium',
dueDate: '',
tags: [],
});
setTouched(new Set());
setErrors({});
} catch (error) {
console.error('Failed to create todo:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={formData.title || ''}
onChange={(e) => handleChange('title', e.target.value)}
onBlur={() => handleBlur('title')}
placeholder="Todo title"
className={errors.title && touched.has('title') ? 'error' : ''}
/>
{errors.title && touched.has('title') && <span className="error-text">{errors.title}</span>}
</div>
<div>
<select
value={formData.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
<option value="low">Low Priority</option>
<option value="medium">Medium Priority</option>
<option value="high">High Priority</option>
</select>
</div>
<div>
<input
type="date"
value={formData.dueDate || ''}
onChange={(e) => handleChange('dueDate', e.target.value)}
onBlur={() => handleBlur('dueDate')}
min={new Date().toISOString().split('T')[0]}
/>
{errors.dueDate && touched.has('dueDate') && (
<span className="error-text">{errors.dueDate}</span>
)}
</div>
<button type="submit" disabled={isSubmitting || Object.keys(errors).length > 0}>
{isSubmitting ? 'Creating...' : 'Create Todo'}
</button>
</form>
);
}Batch Creation
Sometimes you need to create multiple todos at once:
interface BatchCreateResult {
successful: Todo[];
failed: Array<{
data: CreateTodoDTO;
error: string;
}>;
}
function BatchTodoCreator() {
const [todos, setTodos] = useState<Todo[]>([]);
const [batchInput, setBatchInput] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [result, setResult] = useState<BatchCreateResult | null>(null);
const createBatch = async () => {
// Parse input (one todo per line)
const lines = batchInput
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) return;
setIsCreating(true);
setResult(null);
const todosToCreate: CreateTodoDTO[] = lines.map((title) => ({
userId: 1,
title,
completed: false,
}));
const results: BatchCreateResult = {
successful: [],
failed: [],
};
// Create todos in parallel with error handling for each
const promises = todosToCreate.map(async (todoData) => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todoData),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const created: Todo = await response.json();
results.successful.push(created);
} catch (error) {
results.failed.push({
data: todoData,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
await Promise.all(promises);
// Update state with successful todos
if (results.successful.length > 0) {
setTodos((prev) => [...prev, ...results.successful]);
}
setResult(results);
setIsCreating(false);
// Clear input if all successful
if (results.failed.length === 0) {
setBatchInput('');
}
};
const retryFailed = async () => {
if (!result || result.failed.length === 0) return;
const failedTodos = result.failed.map((f) => f.data);
setBatchInput(failedTodos.map((t) => t.title).join('\n'));
setResult(null);
};
return (
<div>
<h3>Batch Create Todos</h3>
<textarea
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
placeholder="Enter todos (one per line)"
rows={5}
disabled={isCreating}
/>
<button onClick={createBatch} disabled={isCreating || !batchInput.trim()}>
{isCreating ? 'Creating...' : 'Create All'}
</button>
{result && (
<div>
{result.successful.length > 0 && <p>✅ Created {result.successful.length} todos</p>}
{result.failed.length > 0 && (
<div>
<p>❌ Failed to create {result.failed.length} todos</p>
<button onClick={retryFailed}>Retry Failed</button>
</div>
)}
</div>
)}
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}Custom Hook for Creation
Let’s encapsulate the creation logic in a reusable hook:
interface UseCreateTodoOptions {
optimistic?: boolean;
onSuccess?: (todo: Todo) => void;
onError?: (error: Error) => void;
}
interface UseCreateTodoReturn {
createTodo: (data: CreateTodoDTO) => Promise<void>;
isCreating: boolean;
error: Error | null;
clearError: () => void;
}
function useCreateTodo(options: UseCreateTodoOptions = {}): UseCreateTodoReturn {
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<Error | null>(null);
const createTodo = useCallback(
async (data: CreateTodoDTO) => {
setIsCreating(true);
setError(null);
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to create todo: ${response.status}`);
}
const created: Todo = await response.json();
options.onSuccess?.(created);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
options.onError?.(error);
} finally {
setIsCreating(false);
}
},
[options],
);
const clearError = useCallback(() => {
setError(null);
}, []);
return { createTodo, isCreating, error, clearError };
}
// Usage
function TodoFormWithHook() {
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState('');
const { createTodo, isCreating, error, clearError } = useCreateTodo({
onSuccess: (todo) => {
setTodos((prev) => [...prev, todo]);
setTitle('');
console.log('Todo created:', todo);
},
onError: (error) => {
console.error('Failed to create todo:', error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
createTodo({
userId: 1,
title: title.trim(),
completed: false,
});
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isCreating}
/>
<button type="submit" disabled={isCreating || !title.trim()}>
{isCreating ? 'Creating...' : 'Add'}
</button>
</form>
{error && (
<div className="error">
{error.message}
<button onClick={clearError}>×</button>
</div>
)}
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}Handling Server-Side Validation
Sometimes the server rejects our todo:
interface ServerError {
field?: string;
message: string;
code?: string;
}
interface ServerValidationError {
errors: ServerError[];
}
async function createTodoWithServerValidation(data: CreateTodoDTO): Promise<Todo> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
if (response.status === 422) {
// Validation error from server
const errorData: ServerValidationError = await response.json();
const errorMessage = errorData.errors.map((e) => e.message).join(', ');
throw new Error(`Validation failed: ${errorMessage}`);
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
function TodoFormWithServerValidation() {
const [title, setTitle] = useState('');
const [serverErrors, setServerErrors] = useState<ServerError[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setServerErrors([]);
setIsSubmitting(true);
try {
await createTodoWithServerValidation({
userId: 1,
title,
completed: false,
});
setTitle('');
} catch (error) {
if (error instanceof Error && error.message.includes('Validation failed')) {
// In a real app, parse the actual server response
setServerErrors([{ field: 'title', message: 'This title already exists' }]);
} else {
setServerErrors([{ message: 'Failed to create todo. Please try again.' }]);
}
} finally {
setIsSubmitting(false);
}
};
const getFieldError = (field: string): string | undefined => {
return serverErrors.find((e) => e.field === field)?.message;
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={getFieldError('title') ? 'error' : ''}
/>
{getFieldError('title') && <span className="error-text">{getFieldError('title')}</span>}
</div>
{serverErrors
.filter((e) => !e.field)
.map((error, i) => (
<div key={i} className="general-error">
{error.message}
</div>
))}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Todo'}
</button>
</form>
);
}Complete Example with All Features
Here’s everything together in a production-ready component:
interface CreateTodoState {
status: 'idle' | 'validating' | 'creating' | 'success' | 'error';
error?: string;
createdTodo?: Todo;
}
function CompleteTodoCreator() {
const [todos, setTodos] = useState<Todo[]>([]);
const [createState, setCreateState] = useState<CreateTodoState>({
status: 'idle',
});
// Form state
const [formData, setFormData] = useState({
title: '',
priority: 'medium' as 'low' | 'medium' | 'high',
});
// Optimistic todos tracking
const [optimisticTodos, setOptimisticTodos] = useState<Map<number, Todo>>(new Map());
const validateForm = (): boolean => {
setCreateState({ status: 'validating' });
if (!formData.title.trim()) {
setCreateState({
status: 'error',
error: 'Title is required',
});
return false;
}
if (formData.title.length < 3) {
setCreateState({
status: 'error',
error: 'Title must be at least 3 characters',
});
return false;
}
return true;
};
const createTodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
const tempId = -Date.now();
const optimisticTodo: Todo = {
id: tempId,
userId: 1,
title: formData.title,
completed: false,
};
// Add optimistic todo
setOptimisticTodos((prev) => new Map(prev).set(tempId, optimisticTodo));
setTodos((prev) => [...prev, optimisticTodo]);
setCreateState({ status: 'creating' });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 1,
title: formData.title,
completed: false,
}),
});
if (!response.ok) {
throw new Error('Failed to create todo');
}
const created: Todo = await response.json();
// Replace optimistic with real todo
setTodos((prev) => prev.map((todo) => (todo.id === tempId ? created : todo)));
setOptimisticTodos((prev) => {
const next = new Map(prev);
next.delete(tempId);
return next;
});
setCreateState({ status: 'success', createdTodo: created });
setFormData({ title: '', priority: 'medium' });
// Reset to idle after showing success
setTimeout(() => {
setCreateState({ status: 'idle' });
}, 2000);
} catch (error) {
// Remove optimistic todo on failure
setTodos((prev) => prev.filter((todo) => todo.id !== tempId));
setOptimisticTodos((prev) => {
const next = new Map(prev);
next.delete(tempId);
return next;
});
setCreateState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to create',
});
}
};
return (
<div>
<form onSubmit={createTodo}>
<div>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({
...prev,
title: e.target.value,
}))
}
placeholder="What needs to be done?"
disabled={createState.status === 'creating'}
className={createState.status === 'error' ? 'error' : ''}
/>
<button
type="submit"
disabled={createState.status === 'creating' || !formData.title.trim()}
>
{createState.status === 'creating' ? 'Creating...' : 'Add'}
</button>
</div>
{createState.status === 'error' && <div className="error-message">{createState.error}</div>}
{createState.status === 'success' && (
<div className="success-message">✅ Created: {createState.createdTodo?.title}</div>
)}
</form>
<ul>
{todos.map((todo) => {
const isOptimistic = optimisticTodos.has(todo.id);
return (
<li key={todo.id} style={{ opacity: isOptimistic ? 0.6 : 1 }}>
{todo.title}
{isOptimistic && <span> (saving...)</span>}
</li>
);
})}
</ul>
</div>
);
}Best Practices
- Always validate before sending - Don’t rely on server validation alone
- Use optimistic updates - But always handle rollback
- Provide clear feedback - Users should know what’s happening
- Handle edge cases - Network failures, server errors, validation errors
- Keep forms accessible - Use proper labels, ARIA attributes, and keyboard support
Summary
Creating todos with TypeScript involves:
- Type-safe form handling
- Client-side validation
- Optimistic updates for better UX
- Proper error handling and recovery
- Server response handling
What’s Next?
Now that we can create todos, let’s explore reading and displaying them with proper pagination, filtering, and search functionality.