Steve Kinney

Creating Todos - POST Requests with TypeScript

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:

  1. User types in a form and submits
  2. We validate the input
  3. Send a POST request to the API
  4. Handle the response (success or failure)
  5. Update local state accordingly
  6. 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

  1. Always validate before sending - Don’t rely on server validation alone
  2. Use optimistic updates - But always handle rollback
  3. Provide clear feedback - Users should know what’s happening
  4. Handle edge cases - Network failures, server errors, validation errors
  5. 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.

Last modified on .