Before you start
Who’s doing Lab 02?
Waiting for your TA to start this lab session. You can read the content in the meantime.
By the end you will
- Explain how React starts, how JSX works, and why apps are built from components
- Pass data with props and render conditions and keyed lists
- Use useState, useEffect, useRef and useMemo correctly
- Build phone screens with
View,Text,Image,TextInput,PressableandFlatList - Style with flexbox and NativeWind classes
- Navigate with Expo Router using stacks, tabs and route parameters
- Share state between screens with Context
- Build a four-screen Movies app and merge it through GitHub
You need
- Lab 01 completed: Expo runs on your phone and Git is configured
- A laptop with Chrome, Edge or Firefox for the interactive exercises
- Expo Go on your phone, or the Android emulator, for the final project
- Your GitHub account and token from Lab 01
Tip
The first half is your fast practice loop: edit, run and check code in the browser. The second half moves the same ideas into a real Expo project on your phone.
Part 1: How a React app starts
10 min
What is React?
Components
You build the UI from small, reusable pieces — like LEGO.
Describe, don’t draw
You say what the screen should show for the current data. React updates the screen for you.
Learn once
React for the web, React Native for phones — same ideas, same code style.
Where does my code start?
- index.htmlan empty <div id="root">
- main.tsxcreateRoot(#root)
- <App />the root component
- children<Header /> <ProductCard />
- UIdrawn in the browser
- expo-routerstarts the app
- app/_layout.tsxthe root layout
- app/index.tsxthe first screen
- children<Header /> <ProductCard />
- UIreal native views
Part 2: Components
10 min
A component is a function that returns UI
function Greeting() {
return <h2>Hello, React!</h2>;
}
// use it like a tag — as many times as you want:
<Greeting />
<Greeting />
Capital letter
Greeting, never greeting — lowercase means an HTML tag.
Returns JSX
HTML-like code inside JavaScript, wrapped in one parent element.
Compose them
Components use other components: App → Header, ProductCard…
- <Header />title + cart
- <SearchBar />input
- <ProductCard />props: 🎧 Headphones
- <ProductCard />props: ⌚ Smart watch
- <TabBar />navigation
ProductCard — the data they show comes in as props.Part 3: Props & hooks
10 min
Props — data from parent to child
PARENT · App
<ProfileCard name="Mariam" role="Student" /><ProfileCard name="Ali" role="Teaching assistant" /><ProfileCard name="Youssef" role="Instructor" />CHILD · ProfileCard
function ProfileCard({ name, role })State — React’s memory (useState)
useEffect — do something after the screen is drawn
useEffect(fn)no array → after every render●●●●useEffect(fn, [])empty → once, after the first render●–––useEffect(fn, [count])first render + whenever count changes●●–●useRef
Keeps a value between renders without re-rendering.
useMemo
Remembers a slow calculation.
useCallback
Remembers a function between renders.
useContext
Shares data with many components (e.g. the logged-in user).
Note
Hooks are functions starting with use that give components extra powers. Call them at the top of a component — never inside an if or a loop. Today you only need useState and useEffect.
The hook details you will need in a real app
useEffect runs after React draws the screen. Its dependency array controls when it runs:
| You write | It runs |
|---|---|
useEffect(fn) | after every render |
useEffect(fn, []) | once, after the first render |
useEffect(fn, [query]) | after the first render and whenever query changes |
return () => ... inside fn | before the effect runs again and when the component unmounts |
const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('https://example.com/movies.json')
.then((response) => response.json())
.then(setMovies)
.finally(() => setLoading(false));
}, []);
useRef returns a box whose .current value survives renders. Attach it to an input when you need to focus that input, or use it for a value that should not redraw the screen.
const titleRef = useRef<TextInput>(null);
function save() {
if (!title.trim()) {
titleRef.current?.focus();
return;
}
}
<TextInput ref={titleRef} value={title} onChangeText={setTitle} />
useMemo remembers the result of a calculation until one of its inputs changes. It is useful for filtering a large list, but ordinary small calculations do not need it.
const filteredMovies = useMemo(
() => movies.filter((movie) => movie.title.toLowerCase().includes(query.toLowerCase())),
[movies, query],
);
Important
Call hooks only at the top level of a component or custom hook. Never put a hook inside an if, loop, event handler, or nested function.
Conditions, lists and stable keys
Use a ternary when exactly one of two things should appear, and && when something is optional:
{loading ? <ActivityIndicator /> : <MovieList movies={movies} />}
{error && <Text className="text-red-600">{error}</Text>}
When .map() creates components, each outer element needs a unique, stable key. Do not use the array index when items can be inserted, removed or reordered.
{movies.map((movie) => <MovieCard key={movie.id} movie={movie} />)}
Part 4: React Native components
10 min
Same React — native building blocks
<div><View>a box / layout<p>, <h1>, <span><Text>all words live here<img src><Image source>pictures (give a size!)<button onClick><Pressable onPress>anything tappable<input onChange><TextInput onChangeText>typingCSS / classNamestyle={{ }} · NativeWindstyling
Part 5: Inputs & state
10 min
A TextInput is controlled by state
const [name, setName] = useState('');
<TextInput value={name} onChangeText={setName} placeholder="Your name" />
<Text>Hello, {name}!</Text>
value
What the box shows — it comes from state.
onChangeText
Called with the new text on every keystroke.
Show / hide
Pick the UI with a condition: {name === '' ? <A /> : <B />}
Part 6: Styling & NativeWind
10 min
Styling in React Native
No CSS files
Styles are JavaScript objects: style={{ padding: 16 }}.
camelCase
backgroundColor, not background-color.
Plain numbers
16 = 16 density-independent pixels. No px.
Flex by default
Every View is a flexbox column.
flexDirection: 'column'the default in React NativejustifyContent moves along the vertical ↓ · alignItems along the horizontal →
flexDirection: 'row'className="flex-row"justifyContent moves along the horizontal → · alignItems along the vertical ↓
div lays children out in a row; in React Native every View is a flex column by default.NativeWind — Tailwind classes for React Native
What it is
Write short utility classes in className — NativeWind turns them into React Native styles.
Why use it
Faster to write, consistent spacing and colours, and you can read the design straight from the code.
<View className="flex-rowitems-centergap-3bg-whiterounded-2xlp-4shadow-md">flex-rowflexDirection: 'row'items-centeralignItems: 'center'gap-3gap: 12bg-whitebackgroundColor: '#ffffff'rounded-2xlborderRadius: 16p-4padding: 16shadow-mdshadowColor: '#000', shadowOffset: {"width":0,"height":4}, shadowOpacity: 0.1, shadowRadius: 6, elevation: 4
Tip
In your own Expo project, follow the official NativeWind install guide once (about 5 minutes) — then className works exactly like it does here.
Part 7: Lists
10 min
Arrays → rows with FlatList
ScrollView renders every child at once — fine for one page of content.
FlatList renders only what’s on screen and creates more rows as you scroll — smooth even with 10,000 items.
<FlatList data={STUDENTS} renderItem={({ item }) => <Row name={item.name} />} />data— the arrayrenderItem— one item → one componentkeyExtractor— which field is unique
Part 8: Putting it together
10 min
One more idea: function props
A child can’t change the parent’s state — but the parent can give it a function to call.
// parent (App): owns the state, passes a function down
<ContactCard name={c.name} favourite={favs.includes(c.id)} onToggle={() => toggle(c.id)} />
// child (ContactCard): calls it when tapped
<Pressable onPress={onToggle}><Text>{favourite ? '★' : '☆'}</Text></Pressable>
Data goes down
Props carry values from parent to child.
Events go up
Function props let the child tell the parent “I was tapped”.
State lives on top
Keep state in the closest parent that needs it.
Part 9: Final challenge
10 min
Build a task list from scratch
Everything you practised today, in one small feature. The checklist is your spec — it fills in as you meet each requirement. Hints unlock one at a time; there are no solutions to copy.
Part 10: Navigation + the Recipes app
30 min
The browser exercises taught each idea separately. Now see how those ideas connect in a real multi-screen Expo app before building your own.
Routing on mobile: Expo Router
7 minExpo Router maps files to screens. Every file inside app/ becomes a route; folders organise the routes, and layout files decide whether screens use a stack or tabs.
- app/
- _layout.tsxwraps every route in a Stack
- (tabs)/route group; not part of the URL
- _layout.tsxbottom tab bar
- index.tsx→ /
- favorites.tsx→ /favorites
- add.tsx→ /add
- recipe/
- [id].tsx→ /recipe/1, /recipe/2 ...
Stack
Pages slide on top of one another and get a Back button.
<Stack />Tabs
A bottom bar switches between the main areas of the app.
<Tabs />Navigate
Navigate in code, or wrap content in <Link href>.
router.push('/add')<Link href={`/recipe/${recipe.id}`} asChild>
<Pressable>
<Text>{recipe.title}</Text>
</Pressable>
</Link>Dynamic routes and route parameters
6 minSquare brackets create a dynamic page. app/recipe/[id].tsx handles /recipe/1, /recipe/2, and every other recipe id.
import { Stack, useLocalSearchParams } from 'expo-router';
export default function RecipeDetails() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipe = recipes.find((item) => item.id === id);
if (!recipe) return <Text>Recipe not found</Text>;
return (
<View>
<Stack.Screen options={{ title: recipe.title }} />
<Text>{recipe.title}</Text>
</View>
);
}Warning
Route parameters are strings. If your data uses numeric ids, compare with item.id === Number(id).
Share state between pages with Context
7 minThe Details page changes favorites, while the Favorites tab displays them. State owned by either page would disappear from the other, so place it in a provider above both screens.
type RecipesValue = {
favorites: string[];
toggleFavorite: (id: string) => void;
};
const RecipesContext = createContext<RecipesValue | null>(null);
export function RecipesProvider({ children }: PropsWithChildren) {
const [favorites, setFavorites] = useState<string[]>([]);
const toggleFavorite = (id: string) =>
setFavorites((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
);
return <RecipesContext.Provider value={{ favorites, toggleFavorite }}>{children}</RecipesContext.Provider>;
}
export function useRecipes() {
const value = useContext(RecipesContext);
if (!value) throw new Error('useRecipes must be inside RecipesProvider');
return value;
}Wrap the Stack with <RecipesProvider> in app/_layout.tsx. Any screen below it can then call useRecipes().
Walk through the complete Recipes app
10 minThis is the complete four-page example from the original Lab 02. Select a file to read its code; use the phone preview to see how the files work together.
The root layout: a Stack (pages slide on top, with Back), wrapped in RecipesProvider so every page shares the same data (Task 5.3).
import { Stack } from 'expo-router';
import { RecipesProvider } from '../context/recipes';
// Root layout: a Stack (pages slide on top of each other, with a Back button),
// wrapped in the provider so every page shares the same recipes and favorites.
export default function RootLayout() {
return (
<RecipesProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="recipe/[id]" options={{ title: 'Recipe' }} />
</Stack>
</RecipesProvider>
);
}The tab bar. Each file in (tabs)/ becomes a tab; (tabs) is a *group*, so it is not part of the URL.
import { Tabs } from 'expo-router';
import { Text } from 'react-native';
// Every file in app/(tabs)/ becomes a tab. (tabs) is a group: it is not part of the URL.
export default function TabsLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#e8590c' }}>
<Tabs.Screen name="index" options={{ title: 'Recipes', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 18 }}>🍽</Text> }} />
<Tabs.Screen name="favorites" options={{ title: 'Favorites', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 18 }}>♥</Text> }} />
<Tabs.Screen name="add" options={{ title: 'Add', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 18 }}>+</Text> }} />
</Tabs>
);
}Page 1: Home. A FlatList of RecipeCards and a search box filtered with useMemo (exercise E9). URL: /
import { useMemo, useState } from 'react';
import { FlatList, Text, TextInput, View, StyleSheet } from 'react-native';
import RecipeCard from '../../components/RecipeCard';
import { useRecipes } from '../../context/recipes';
// Page 1: all recipes + search
export default function HomeScreen() {
const { recipes } = useRecipes();
const [query, setQuery] = useState('');
// only re-filter when the list or the query changes
const visible = useMemo(
() => recipes.filter((r) => r.title.toLowerCase().includes(query.toLowerCase())),
[recipes, query],
);
return (
<View style={styles.screen}>
<TextInput style={styles.search} placeholder="Search recipes" value={query} onChangeText={setQuery} />
<FlatList
data={visible}
keyExtractor={(r) => r.id}
renderItem={({ item }) => <RecipeCard recipe={item} />}
ListEmptyComponent={<Text style={styles.empty}>No recipe matches "{query}"</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 14, backgroundColor: '#fff' },
search: { borderWidth: 1, borderColor: '#fed7aa', borderRadius: 12, padding: 10, marginBottom: 12, fontSize: 15 },
empty: { textAlign: 'center', color: '#94a3b8', marginTop: 24 },
});Page 3: Favorites. Filters the shared recipes with isFavorite from the context. URL: /favorites
import { FlatList, Text, View, StyleSheet } from 'react-native';
import RecipeCard from '../../components/RecipeCard';
import { useRecipes } from '../../context/recipes';
// Page 3: only the favorites. The data comes from the shared context.
export default function FavoritesScreen() {
const { recipes, isFavorite } = useRecipes();
const favorites = recipes.filter((r) => isFavorite(r.id));
return (
<View style={styles.screen}>
<Text style={styles.count}>{favorites.length} {favorites.length === 1 ? 'favorite' : 'favorites'}</Text>
<FlatList
data={favorites}
keyExtractor={(r) => r.id}
renderItem={({ item }) => <RecipeCard recipe={item} />}
ListEmptyComponent={<Text style={styles.empty}>No favorites yet: tap ♡ on a recipe</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 14, backgroundColor: '#fff' },
count: { color: '#64748b', marginBottom: 10 },
empty: { textAlign: 'center', color: '#94a3b8', marginTop: 24 },
});Page 4: Add. A form with useState, validation, useRef to focus the bad field (E6, E8), then router.push('/'). URL: /add
import { useRef, useState } from 'react';
import { router } from 'expo-router';
import { Pressable, Text, TextInput, View, StyleSheet } from 'react-native';
import { useRecipes } from '../../context/recipes';
// Page 4: a form. Validate, add to the shared list, go back to Home.
export default function AddScreen() {
const { addRecipe } = useRecipes();
const [title, setTitle] = useState('');
const [emoji, setEmoji] = useState('');
const [minutes, setMinutes] = useState('');
const [error, setError] = useState('');
const titleRef = useRef<TextInput>(null);
function save() {
if (!title.trim()) {
setError('Title is required');
titleRef.current?.focus();
return;
}
const mins = Number(minutes);
if (!Number.isInteger(mins) || mins <= 0) {
setError('Minutes must be a positive number');
return;
}
addRecipe({ title: title.trim(), emoji: emoji.trim(), minutes: mins });
setTitle('');
setEmoji('');
setMinutes('');
setError('');
router.push('/');
}
return (
<View style={styles.screen}>
<Text style={styles.label}>Title</Text>
<TextInput ref={titleRef} style={styles.input} placeholder="Title" value={title} onChangeText={setTitle} />
<Text style={styles.label}>Emoji</Text>
<TextInput style={styles.input} placeholder="Emoji" value={emoji} onChangeText={setEmoji} />
<Text style={styles.label}>Minutes</Text>
<TextInput style={styles.input} placeholder="Minutes" value={minutes} onChangeText={setMinutes} keyboardType="number-pad" />
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable style={styles.save} onPress={save}>
<Text style={styles.saveText}>Save recipe</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 18, gap: 6, backgroundColor: '#fff' },
label: { fontWeight: '700', marginTop: 6 },
input: { borderWidth: 1, borderColor: '#fed7aa', borderRadius: 12, padding: 10, fontSize: 15 },
error: { color: '#dc2626', marginTop: 6 },
save: { marginTop: 14, backgroundColor: '#e8590c', padding: 14, borderRadius: 12, alignItems: 'center' },
saveText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});Page 2: Details. [id] is a route param, read with useLocalSearchParams(). <Stack.Screen options> sets the header title (Task 5.2). URL: /recipe/1
import { Stack, useLocalSearchParams } from 'expo-router';
import { Pressable, ScrollView, Text, StyleSheet } from 'react-native';
import { useRecipes } from '../../context/recipes';
// Page 2: one recipe. The [id] in the file name is a route param: /recipe/3 → id = "3"
export default function RecipeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { recipes, isFavorite, toggleFavorite } = useRecipes();
const recipe = recipes.find((r) => r.id === id);
if (!recipe) return <Text style={styles.missing}>Recipe not found</Text>;
const fav = isFavorite(recipe.id);
return (
<ScrollView contentContainerStyle={styles.screen}>
{/* change the header title from inside the page */}
<Stack.Screen options={{ title: recipe.title }} />
<Text style={styles.emoji}>{recipe.emoji}</Text>
<Text style={styles.title}>{recipe.title}</Text>
<Text style={styles.meta}>⏱ {recipe.minutes} min</Text>
<Pressable style={[styles.fav, fav && styles.favOn]} onPress={() => toggleFavorite(recipe.id)}>
<Text style={[styles.favText, fav && styles.favTextOn]}>{fav ? '♥ Remove from favorites' : '♡ Add to favorites'}</Text>
</Pressable>
{recipe.ingredients.length > 0 && <Text style={styles.h}>Ingredients</Text>}
{recipe.ingredients.map((x) => <Text key={x} style={styles.li}>• {x}</Text>)}
{recipe.steps.length > 0 && <Text style={styles.h}>Steps</Text>}
{recipe.steps.map((s, i) => <Text key={s} style={styles.li}>{i + 1}. {s}</Text>)}
</ScrollView>
);
}
const styles = StyleSheet.create({
screen: { padding: 20, alignItems: 'center', backgroundColor: '#fff' },
missing: { padding: 24, textAlign: 'center' },
emoji: { fontSize: 64 },
title: { fontSize: 24, fontWeight: '800', marginTop: 4 },
meta: { color: '#9a3412', marginTop: 4 },
fav: { marginVertical: 16, paddingVertical: 10, paddingHorizontal: 18, borderRadius: 999, borderWidth: 1.5, borderColor: '#e8590c' },
favOn: { backgroundColor: '#e8590c' },
favText: { color: '#e8590c', fontWeight: '700' },
favTextOn: { color: '#fff' },
h: { alignSelf: 'stretch', fontSize: 16, fontWeight: '700', marginTop: 10, marginBottom: 4 },
li: { alignSelf: 'stretch', color: '#334155', marginBottom: 4 },
});A reusable component with props (E2). <Link href asChild> makes the whole card open the Details page.
import { Link } from 'expo-router';
import { Pressable, Text, View, StyleSheet } from 'react-native';
import type { Recipe } from '../data/recipes';
import { useRecipes } from '../context/recipes';
// props: the recipe to show. Tapping it opens /recipe/<id>
export default function RecipeCard({ recipe }: { recipe: Recipe }) {
const { isFavorite } = useRecipes();
return (
<Link href={`/recipe/${recipe.id}`} asChild>
<Pressable style={styles.card}>
<Text style={styles.emoji}>{recipe.emoji}</Text>
<View style={styles.info}>
<Text style={styles.title}>{recipe.title}</Text>
<Text style={styles.meta}>⏱ {recipe.minutes} min</Text>
</View>
{isFavorite(recipe.id) && <Text style={styles.heart}>♥</Text>}
</Pressable>
</Link>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 12, marginBottom: 10, borderRadius: 14, backgroundColor: '#fff7ed' },
emoji: { fontSize: 32 },
info: { flex: 1 },
title: { fontSize: 16, fontWeight: '700' },
meta: { color: '#9a3412', marginTop: 2 },
heart: { fontSize: 18, color: '#e8590c' },
});Shared state: createContext + a provider that owns the state + a custom hook useRecipes() that any page can call.
import { createContext, useContext, useState, type ReactNode } from 'react';
import { RECIPES, type Recipe } from '../data/recipes';
type NewRecipe = { title: string; emoji: string; minutes: number };
type RecipesValue = {
recipes: Recipe[];
isFavorite: (id: string) => boolean;
toggleFavorite: (id: string) => void;
addRecipe: (r: NewRecipe) => void;
};
// 1. create the context (the "shared box")
const RecipesContext = createContext<RecipesValue | null>(null);
// 2. the provider owns the state and wraps the whole app (see app/_layout.tsx)
export function RecipesProvider({ children }: { children: ReactNode }) {
const [recipes, setRecipes] = useState<Recipe[]>(RECIPES);
const [favorites, setFavorites] = useState<string[]>([]);
const value: RecipesValue = {
recipes,
isFavorite: (id) => favorites.includes(id),
toggleFavorite: (id) =>
setFavorites((f) => (f.includes(id) ? f.filter((x) => x !== id) : [...f, id])),
addRecipe: ({ title, emoji, minutes }) =>
setRecipes((r) => [
{ id: String(Date.now()), title, emoji: emoji || '🍽️', minutes, ingredients: [], steps: [] },
...r,
]),
};
return <RecipesContext.Provider value={value}>{children}</RecipesContext.Provider>;
}
// 3. a custom hook: any page calls useRecipes() to read or change the shared state
export function useRecipes() {
const value = useContext(RecipesContext);
if (!value) throw new Error('useRecipes() must be used inside <RecipesProvider>');
return value;
}Plain data with a TypeScript type.
export type Recipe = {
id: string;
title: string;
emoji: string;
minutes: number;
ingredients: string[];
steps: string[];
};
export const RECIPES: Recipe[] = [
{
id: '1', title: 'Koshari', emoji: '🍝', minutes: 45,
ingredients: ['Rice', 'Lentils', 'Pasta', 'Tomato sauce', 'Crispy onions'],
steps: ['Cook rice, lentils and pasta separately.', 'Simmer a garlicky tomato sauce.', 'Layer everything and top with onions.'],
},
{
id: '2', title: 'Shakshuka', emoji: '🍳', minutes: 25,
ingredients: ['Eggs', 'Tomatoes', 'Pepper', 'Onion', 'Cumin'],
steps: ['Soften onion and pepper.', 'Add tomatoes and cumin, simmer.', 'Crack in the eggs and cover until set.'],
},
{
id: '3', title: 'Falafel', emoji: '🧆', minutes: 30,
ingredients: ['Fava beans', 'Herbs', 'Garlic', 'Sesame'],
steps: ['Blend soaked beans with herbs and garlic.', 'Shape into discs and dip in sesame.', 'Fry until golden.'],
},
{
id: '4', title: 'Lentil Soup', emoji: '🥣', minutes: 35,
ingredients: ['Red lentils', 'Carrot', 'Onion', 'Cumin', 'Lemon'],
steps: ['Boil lentils with carrot and onion.', 'Blend until smooth.', 'Season with cumin and lemon.'],
},
{
id: '5', title: 'Fattah', emoji: '🍚', minutes: 60,
ingredients: ['Rice', 'Toasted bread', 'Garlic vinegar sauce', 'Beef'],
steps: ['Cook the beef and its broth.', 'Layer bread, rice and sauce.', 'Top with the beef.'],
},
{
id: '6', title: 'Basbousa', emoji: '🍰', minutes: 50,
ingredients: ['Semolina', 'Yogurt', 'Sugar syrup', 'Coconut'],
steps: ['Mix semolina, yogurt and coconut.', 'Bake until golden.', 'Pour cold syrup over the hot cake.'],
},
];Starting…
● live: tap around
Find the code responsible for each behaviour:
- Search for
fal. Which hook calculates the filtered list, and what are its dependencies? - Open Koshari, add it to favorites, then open the Favorites tab. Which provider makes that possible?
- Press Save recipe with an empty title. Where is the error set, and how does the ref focus the title input?
- Add a recipe. Which function creates the new array, and why would
recipes.push(...)be wrong?
Part 11: Your project: a four-screen Movies app
45 min
Build the same architecture yourself in a real Expo project. The specifications below are deliberately detailed, but the implementation is yours.
Create and organise the project
7 minThe tabs template already includes Expo Router, a Stack and a tab bar.
Reshape the template into this structure:
- app/
- _layout.tsxStack + MoviesProvider
- (tabs)/
- _layout.tsxMovies · Watchlist · Add
- index.tsxPage 1: Movies
- watchlist.tsxPage 3: Watchlist
- add.tsxPage 4: Add movie
- movie/
- [id].tsxPage 2: Movie details
- components/
- MovieCard.tsxreceives one Movie prop
- context/
- movies.tsxshared list + watchlist state
- data/
- movies.tsstarting data + Movie type
export type Movie = {
id: string;
title: string;
year: number;
genre: string;
rating: number;
emoji: string;
};
export const MOVIES: Movie[] = [
{ id: '1', title: 'Inception', year: 2010, genre: 'Sci-Fi', rating: 8.8, emoji: '🌀' },
{ id: '2', title: 'The Lion King', year: 1994, genre: 'Animation', rating: 8.5, emoji: '🦁' },
{ id: '3', title: 'Interstellar', year: 2014, genre: 'Sci-Fi', rating: 8.7, emoji: '🪐' },
{ id: '4', title: 'Spirited Away', year: 2001, genre: 'Animation', rating: 8.6, emoji: '🐉' },
{ id: '5', title: 'The Dark Knight', year: 2008, genre: 'Action', rating: 9.0, emoji: '🦇' },
{ id: '6', title: 'Coco', year: 2017, genre: 'Animation', rating: 8.4, emoji: '💀' },
];Page 1: Movies list and search
9 minYour page must include:
- A
FlatListof every movie with a stablekeyExtractor. - A reusable
MovieCardreceiving one typedmovieprop. - Emoji, title, year and rating on each card.
- A
LinkwithasChildthat opens/movie/<id>when the card is pressed. - A controlled search box and a
useMemofiltered list. - A useful empty result such as “No movie matches…”.
Page 2: Movie details
9 minYour page must:
- Read
idwithuseLocalSearchParamsand find the corresponding movie. - Display its emoji, title, year, genre and rating.
- Set the Stack header title to the movie title.
- Toggle between Add to watchlist and Remove from watchlist.
- Render “Movie not found” for an invalid id.
The watchlist is used here and on Page 3, so own it in context/movies.tsx, expose it with useMovies(), and wrap the app in MoviesProvider.
Page 3: Watchlist
7 minReuse MovieCard, show only saved movies, include the count with correct singular/plural grammar, and provide a friendly empty state.
Page 4: Add movie form
8 minYour form must include:
- Controlled title, year and genre inputs.
- Validation: title is required; year must be a number from 1900 to 2030.
- A red error message and
useRefto focus the field that needs fixing. - A save handler that creates a new movie without mutating the old array.
- Cleared inputs and
router.push('/')after a successful save.
Test, push and merge
5 minBefore pushing, test this complete journey on your phone:
- Search for a movie and open its details.
- Add it to the watchlist and confirm it appears in the Watchlist tab.
- Remove it and confirm the empty state returns.
- Submit the Add form empty and confirm validation focuses the title.
- Add a valid movie and confirm it appears on the Movies page.
- Open an invalid details URL and confirm the app does not crash.
On GitHub choose Compare & pull request → Create pull request → Merge pull request. Then update the local copy:
Paste the repository link for your TA:
Mission complete
Your Movies app has four working pages, shared watchlist state, search, validation, route parameters, and a reviewed Git history merged into main. 🎬
Stuck?
"Text strings must be rendered within a <Text> component"
Text was placed directly inside a View.
Wrap every piece of text in <Text>…</Text>.
"Each child in a list should have a unique key prop"
A .map() returns items without a stable key.
Add key={item.id}, or use keyExtractor on a FlatList.
"Too many re-renders"
State is changed while rendering, such as onPress={setCount(1)}.
Pass a function instead: onPress={() => setCount(1)}.
"Rendered more hooks than during the previous render"
A hook is inside an if, loop, nested function, or after an early return.
Call every hook at the top of the component in the same order.
The app fetches or re-renders forever
A useEffect changes state but has a missing or incorrect dependency array.
Use [] for once, or list every reactive value the effect reads.
"Unmatched route" or the page is not found
The file is outside app/, or the link does not match its file name.
app/movie/[id].tsx is opened with /movie/3; check both spellings.
Expo Go keeps loading or cannot connect
The phone and laptop are on different networks, or the network blocks discovery.
Stop the server and start it in tunnel mode.
npx expo start --tunnelStill stuck? Search the troubleshooting center or ask your TA.
Submission
- All 17 interactive exercises show Solved
- A link to the movies-app GitHub repository with
feature/movies-appmerged intomain - A screenshot of each of the four pages running on your phone
Extra challenge
Persist the watchlist with @react-native-async-storage/async-storage: load it once at startup and save it whenever it changes.
Resources
End of lab · attendance
Check in for Lab 02
Checking whether check-in is open…
