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.
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.
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.
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.
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.
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.
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.
import { TaskListScreen } from '@/presentation/screens/TaskListScreen';
export default TaskListScreen;Update the tab layout to include the new screen:
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:
yarn start
# then press 'i' for iOS or 'a' for AndroidKey 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.