Create Your First Screen

Learn how to build a complete feature from scratch using the MVVM pattern. We'll create a task list screen with full CRUD operations.

This guide assumes you've already installed the boilerplate and understand the MVVM architecture.

What We'll Build

A task management screen that demonstrates:

  • Creating entities and schemas (Model)
  • Building a ViewModel with state management
  • Creating the View with proper separation of concerns
  • Adding navigation and routing

Step 1: Define the Entity (Model)

First, let's create the data structure for our tasks.

src/domain/entities/task.ts
export interface Task {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateTaskInput {
  title: string;
  description?: string;
}

export interface UpdateTaskInput {
  id: string;
  title?: string;
  description?: string;
  completed?: boolean;
}

Step 2: Create Validation Schema

Add Zod schemas for type-safe validation.

src/domain/schemas/task.schema.ts
import { z } from 'zod';

export const createTaskSchema = z.object({
  title: z.string().min(1, 'Title is required').max(100),
  description: z.string().max(500).optional(),
});

export const updateTaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(100).optional(),
  description: z.string().max(500).optional(),
  completed: z.boolean().optional(),
});

Step 3: Create the Store (State Management)

Use Zustand for global task state with MMKV persistence.

src/stores/task.store.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { zustandStorage } from './mmkv';
import type { Task, CreateTaskInput, UpdateTaskInput } from '@/domain/entities/task';

interface TaskStore {
  tasks: Task[];
  addTask: (input: CreateTaskInput) => void;
  updateTask: (input: UpdateTaskInput) => void;
  deleteTask: (id: string) => void;
  toggleTask: (id: string) => void;
  clearCompleted: () => void;
}

export const useTaskStore = create<TaskStore>()(
  persist(
    (set) => ({
      tasks: [],

      addTask: (input) => set((state) => ({
        tasks: [
          ...state.tasks,
          {
            id: crypto.randomUUID(),
            ...input,
            completed: false,
            createdAt: new Date(),
            updatedAt: new Date(),
          },
        ],
      })),

      updateTask: ({ id, ...updates }) => set((state) => ({
        tasks: state.tasks.map((task) =>
          task.id === id
            ? { ...task, ...updates, updatedAt: new Date() }
            : task
        ),
      })),

      deleteTask: (id) => set((state) => ({
        tasks: state.tasks.filter((task) => task.id !== id),
      })),

      toggleTask: (id) => set((state) => ({
        tasks: state.tasks.map((task) =>
          task.id === id
            ? { ...task, completed: !task.completed, updatedAt: new Date() }
            : task
        ),
      })),

      clearCompleted: () => set((state) => ({
        tasks: state.tasks.filter((task) => !task.completed),
      })),
    }),
    {
      name: 'task-storage',
      storage: createJSONStorage(() => zustandStorage),
    }
  )
);

Step 4: Build the ViewModel

Create the presentation logic and expose a clean interface for the View.

src/presentation/viewmodels/useTaskListViewModel.ts
import { useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTaskStore } from '@/stores/task.store';
import { createTaskSchema } from '@/domain/schemas/task.schema';
import type { CreateTaskInput } from '@/domain/entities/task';

export function useTaskListViewModel() {
  // Access store
  const tasks = useTaskStore((state) => state.tasks);
  const addTask = useTaskStore((state) => state.addTask);
  const toggleTask = useTaskStore((state) => state.toggleTask);
  const deleteTask = useTaskStore((state) => state.deleteTask);
  const clearCompleted = useTaskStore((state) => state.clearCompleted);

  // Form handling
  const form = useForm<CreateTaskInput>({
    resolver: zodResolver(createTaskSchema),
    defaultValues: {
      title: '',
      description: '',
    },
  });

  // Computed values
  const activeTasks = useMemo(
    () => tasks.filter((t) => !t.completed),
    [tasks]
  );

  const completedTasks = useMemo(
    () => tasks.filter((t) => t.completed),
    [tasks]
  );

  const stats = useMemo(
    () => ({
      total: tasks.length,
      active: activeTasks.length,
      completed: completedTasks.length,
      completionRate: tasks.length > 0
        ? (completedTasks.length / tasks.length) * 100
        : 0,
    }),
    [tasks, activeTasks, completedTasks]
  );

  // Actions
  const handleAddTask = useCallback(
    (data: CreateTaskInput) => {
      addTask(data);
      form.reset();
    },
    [addTask, form]
  );

  const handleToggleTask = useCallback(
    (id: string) => {
      toggleTask(id);
    },
    [toggleTask]
  );

  const handleDeleteTask = useCallback(
    (id: string) => {
      deleteTask(id);
    },
    [deleteTask]
  );

  const handleClearCompleted = useCallback(() => {
    clearCompleted();
  }, [clearCompleted]);

  return {
    // Data
    tasks,
    activeTasks,
    completedTasks,
    stats,
    
    // Form
    form,
    
    // Actions
    handleAddTask,
    handleToggleTask,
    handleDeleteTask,
    handleClearCompleted,
  };
}

Step 5: Create the Screen (View)

Build the UI component that displays data from the ViewModel.

src/presentation/screens/TaskListScreen.tsx
import { View, Text, FlatList, TouchableOpacity, TextInput } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Controller } from 'react-hook-form';
import { useTaskListViewModel } from '../viewmodels/useTaskListViewModel';

export function TaskListScreen() {
  const vm = useTaskListViewModel();

  return (
    <SafeAreaView className="flex-1 bg-slate-950">
      <View className="flex-1 px-4">
        {/* Header */}
        <View className="py-6">
          <Text className="text-3xl font-bold text-white">Tasks</Text>
          <View className="flex-row gap-4 mt-4">
            <View className="flex-1 glass rounded-lg p-3">
              <Text className="text-slate-400 text-xs">Active</Text>
              <Text className="text-white text-xl font-bold">
                {vm.stats.active}
              </Text>
            </View>
            <View className="flex-1 glass rounded-lg p-3">
              <Text className="text-slate-400 text-xs">Completed</Text>
              <Text className="text-white text-xl font-bold">
                {vm.stats.completed}
              </Text>
            </View>
            <View className="flex-1 glass rounded-lg p-3">
              <Text className="text-slate-400 text-xs">Progress</Text>
              <Text className="text-white text-xl font-bold">
                {vm.stats.completionRate.toFixed(0)}%
              </Text>
            </View>
          </View>
        </View>

        {/* Add Task Form */}
        <View className="glass rounded-xl p-4 mb-4">
          <Controller
            control={vm.form.control}
            name="title"
            render={({ field, fieldState }) => (
              <View className="mb-3">
                <TextInput
                  value={field.value}
                  onChangeText={field.onChange}
                  placeholder="Task title"
                  placeholderTextColor="#64748b"
                  className="text-white text-base"
                />
                {fieldState.error && (
                  <Text className="text-red-400 text-xs mt-1">
                    {fieldState.error.message}
                  </Text>
                )}
              </View>
            )}
          />

          <TouchableOpacity
            onPress={vm.form.handleSubmit(vm.handleAddTask)}
            className="bg-blue-500 rounded-lg py-3"
          >
            <Text className="text-white text-center font-semibold">
              Add Task
            </Text>
          </TouchableOpacity>
        </View>

        {/* Task List */}
        <FlatList
          data={vm.tasks}
          keyExtractor={(item) => item.id}
          renderItem={({ item }) => (
            <View className="glass rounded-xl p-4 mb-3 flex-row items-center">
              <TouchableOpacity
                onPress={() => vm.handleToggleTask(item.id)}
                className="mr-3"
              >
                <View
                  className={`w-6 h-6 rounded-full border-2 ${
                    item.completed
                      ? 'bg-blue-500 border-blue-500'
                      : 'border-slate-600'
                  }`}
                >
                  {item.completed && (
                    <Text className="text-white text-center">✓</Text>
                  )}
                </View>
              </TouchableOpacity>

              <View className="flex-1">
                <Text
                  className={`text-white font-medium ${
                    item.completed ? 'line-through opacity-50' : ''
                  }`}
                >
                  {item.title}
                </Text>
              </View>

              <TouchableOpacity
                onPress={() => vm.handleDeleteTask(item.id)}
                className="ml-3"
              >
                <Text className="text-red-400">Delete</Text>
              </TouchableOpacity>
            </View>
          )}
          ListEmptyComponent={() => (
            <View className="items-center justify-center py-12">
              <Text className="text-slate-400">No tasks yet</Text>
            </View>
          )}
        />

        {/* Clear Completed */}
        {vm.stats.completed > 0 && (
          <TouchableOpacity
            onPress={vm.handleClearCompleted}
            className="glass rounded-xl py-4 mb-4"
          >
            <Text className="text-red-400 text-center font-semibold">
              Clear Completed ({vm.stats.completed})
            </Text>
          </TouchableOpacity>
        )}
      </View>
    </SafeAreaView>
  );
}

Step 6: Add Navigation Route

Create the route file using Expo Router.

src/app/(tabs)/tasks.tsx
import { TaskListScreen } from '@/presentation/screens/TaskListScreen';

export default TaskListScreen;

Update the tab layout to include the new screen:

src/app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="tasks" options={{ title: 'Tasks' }} />
      <Tabs.Screen name="settings" options={{ title: 'Settings' }} />
    </Tabs>
  );
}

Testing Your Screen

Run your app to see the new task screen:

bash
yarn start
# then press 'i' for iOS or 'a' for Android
🎉 Congratulations! You've created a complete feature using the MVVM pattern.

Key Takeaways

✅ Model Layer

Entities, schemas, and stores define your data structure and business logic.

✅ ViewModel Layer

Custom hooks manage state, computed values, and actions for the screen.

✅ View Layer

React components only render UI and delegate all logic to the ViewModel.

✅ Separation of Concerns

Each layer has a clear responsibility, making the code easy to test and maintain.

Next Steps