Handling User Inputs page
Use switch controls and inputs to collect information.
Overview
In this section, you will:
- Handle checkbox-like behavior with
<Switch>
. - Use TypeScript’s
Record
interface. - Set state using an updater function.
- Update reference types and rendering.
- Use the
TextInput
component. - Label text inputs.
- Create unique IDs with the
useId()
Hook.
Objective 1: Add switch controls to select menu items
Now let’s create a RestaurantOrder
view for users to start selecting menu items!
We’ll start with switch controls to select menu items, with a message that warns users when no items are selected and a total that shows the sum of the selected items.
Handling checkbox-like behavior with <Switch>
For boolean values, such as toggles, React Native provides the Switch
component.
You handle changes using the onValueChange
prop, which directly provides the new boolean value.
[If you’re familiar with web development, React Native does not have a direct equivalent to the HTML <input type="checkbox">
.
Instead, you use the <Switch>
component to achieve similar functionality.]
Let’s take a look at an example using <Switch>
:
import { useState } from "react"
import { Switch, Text, View } from "react-native"
const SwitchExample = () => {
const [isEnabled, setIsEnabled] = useState(false)
return (
<View>
<Switch
onValueChange={(newValue) => setIsEnabled(newValue)}
thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"}
trackColor={{ false: "#767577", true: "#81b0ff" }}
value={isEnabled}
/>
<Text>{isEnabled ? "Enabled" : "Disabled"}</Text>
</View>
)
}
export default SwitchExample
In the example above, you can see the value
passed in is the isEnabled
state.
Then, the onValueChange
prop takes a function that will be called with the new value when the switch is toggled.
This is usually what you need, although there is an onChange
prop if you need your function to be called with an event object instead.
Using TypeScript’s Record
interface
In our upcoming exercise, we want to store information in a JavaScript object. We also want to use TypeScript so we can constrain the types used as keys and values.
TypeScript provides a handy interface named Record
that we can use.
Record
is a generic interface that requires two types: the first is the type of the keys, and the second is the type of the values.
For example, if we’re recording the items in a list that are selected, we might capture the item’s name and whether or not it’s selected like this:
import { useState } from "react"
import { View, Text, Switch } from "react-native"
const landmarks = [
{ id: "0b90c705", name: "Eiffel Tower" },
{ id: "5be758c1", name: "Machu Picchu" },
{ id: "206025c3", name: "Taj Mahal" },
]
type SelectedItems = Record<string, boolean>
const Selected = () => {
const [selected, setSelected] = useState<SelectedItems>({})
function handleChange(name: string, isSelected: boolean) {
setSelected((current) => ({ ...current, [name]: isSelected }))
}
return (
<View>
{landmarks.map((landmark) => {
return (
<View
key={landmark.id}
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 10,
}}
>
<Text>{landmark.name}: </Text>
<Switch
onValueChange={(newValue) =>
handleChange(landmark.name, newValue)
}
value={!!selected[landmark.name]}
/>
</View>
)
})}
</View>
)
}
export default Selected
We’ve explicitly defined the type of useState
as a Record<string, boolean>
; all the keys must be strings, and all the values must be booleans.
Fortunately, JavaScript’s object
implements the Record
interface, so we can set the default value to an empty object
instance.
Now let’s see how we can use a Record
to store state data.
Setting state using an updater function
One challenge we face when using an object
for state is that we probably need to merge the current state value with the new state value.
Why?
Imagine we have a state object that already has multiple keys and values, and we need to add a new key and value.
Well, we’re in luck!
React already has a solution for this: the setter function returned by useState
will accept an “updater function” that’s passed the “current” state value and should return the “next” state value.
import { useState } from "react"
import { View, Text, Switch } from "react-native"
const landmarks = [
{ id: "0b90c705", name: "Eiffel Tower" },
{ id: "5be758c1", name: "Machu Picchu" },
{ id: "206025c3", name: "Taj Mahal" },
]
type SelectedItems = Record<string, boolean>
const Selected: React.FC = () => {
const [selected, setSelected] = useState<SelectedItems>({})
function handleSelectedChange(name: string, isSelected: boolean) {
setSelected((currentSelectedItems) => {
const updatedSelectedItems = {
...currentSelectedItems,
}
if (isSelected) {
updatedSelectedItems[name] = true
} else {
delete updatedSelectedItems[name]
}
return updatedSelectedItems
})
}
return (
<View>
{landmarks.map((landmark) => (
<View
key={landmark.id}
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 10,
}}
>
<Text>{landmark.name}: </Text>
<Switch
onValueChange={(newValue) =>
handleSelectedChange(landmark.name, newValue)
}
value={!!selected[landmark.name]}
/>
</View>
))}
</View>
)
}
export default Selected
In the example above, the onChange
event handler calls handleSelectedChange
, which accepts a name string
and a boolean
.
In turn, handleSelectedChange
calls setSelected
with an updater function as the argument.
The updater function accepts the currentSelectedItems
argument, which is the object with the currently-selected items before our switch was toggled.
We will dig into how we create the updatedSelectedItems
object in just a bit, but for now let’s take note that we create a new updatedSelectedItems
object and return it from our updater function.
This gives React the updated selected
state and allows React to re-render the component.
Updating reference types and rendering
Now let’s explain how the updater function works in the example above. The updater function does not mutate the current object, then return it; instead, it makes a new object and populates it with the contents of the current object.
This is an important detail because, after the updater function runs, React will compare the values of the current and next objects to determine if they are different. If they are different, React will re-render the Selected
component; if they are the same, then React will do nothing.
The same rules apply when state is an array: create a new array, then update the contents of the new array.
Here’s how to add an item when state (current
) is an array:
setSelectedOrders((current) => {
const next = [...current, newOrder]
return next
})
Here’s how to replace an item when state (current
) is an array:
setUpdatedRestaurant((current) => {
const next = [
...current.filter((item) => item.id !== updatedRestaurant.id),
updatedRestaurant,
]
return next
})
OK, that was a lot. Let’s start making some code changes so we can select menu items for an order.
Setup 1
✏️ Update src/App.tsx to be:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import Icon from "react-native-vector-icons/Ionicons"
import Box from "./design/Box"
import ThemeProvider, { useTheme } from "./design/theme/ThemeProvider"
import Typography from "./design/Typography"
import CityList from "./screens/CityList"
import RestaurantDetails from "./screens/RestaurantDetails"
import RestaurantList from "./screens/RestaurantList"
import RestaurantOrder from "./screens/RestaurantOrder"
import Settings from "./screens/Settings"
import StateList from "./screens/StateList"
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RootParamList extends RestaurantsStackParamList {}
}
}
export type RestaurantsStackParamList = {
StateList: undefined
CityList: {
state: {
name: string
short: string
}
}
RestaurantList: {
state: {
name: string
short: string
}
city: {
name: string
state: string
}
}
RestaurantDetails: {
slug: string
}
RestaurantOrder: {
slug: string
}
}
const RestaurantsStack = createStackNavigator<RestaurantsStackParamList>()
const RestaurantsNavigator: React.FC = () => {
return (
<RestaurantsStack.Navigator
initialRouteName="StateList"
screenOptions={{
header: ({ route, navigation }) => {
if (!navigation.canGoBack()) return null
return (
<Pressable onPress={navigation.goBack}>
<Box
padding="m"
style={{ flexDirection: "row", gap: 8, alignItems: "center" }}
>
<Icon name="arrow-back" size={20} />
<Typography variant="heading">
{/* @ts-ignore */}
{[route.params?.city?.name, route.params?.state?.name]
.filter(Boolean)
.join(", ")}
</Typography>
</Box>
</Pressable>
)
},
}}
>
<RestaurantsStack.Screen name="StateList" component={StateList} />
<RestaurantsStack.Screen name="CityList" component={CityList} />
<RestaurantsStack.Screen
name="RestaurantList"
component={RestaurantList}
/>
<RestaurantsStack.Screen
name="RestaurantDetails"
component={RestaurantDetails}
/>
{/* Exercise: Add RestaurantOrder page to the stack. */}
</RestaurantsStack.Navigator>
)
}
const AppTabs = createBottomTabNavigator()
const AppNavigator: React.FC = () => {
const theme = useTheme()
return (
<AppTabs.Navigator
initialRouteName="RestaurantsStack"
screenOptions={({ route }) => ({
headerStyle: {
backgroundColor: theme.palette.screen.main,
},
headerTitleStyle: {
color: theme.palette.screen.contrast,
...theme.typography.title,
},
tabBarStyle: {
backgroundColor: theme.palette.screen.main,
},
tabBarActiveTintColor: theme.palette.primary.strong,
tabBarInactiveTintColor: theme.palette.screen.contrast,
tabBarIcon: ({ focused, color }) => {
let icon = "settings"
if (route.name === "Settings") {
icon = focused ? "settings" : "settings-outline"
} else if (route.name === "Restaurants") {
icon = focused ? "restaurant" : "restaurant-outline"
}
return <Icon name={icon} size={20} color={color} />
},
})}
>
<AppTabs.Screen
name="Restaurants"
component={RestaurantsNavigator}
options={{ title: "Place My Order" }}
/>
<AppTabs.Screen
name="Settings"
component={Settings}
options={{ title: "Settings" }}
/>
</AppTabs.Navigator>
)
}
const App: React.FC = () => {
return (
<SafeAreaView style={{ flex: 1 }}>
<ThemeProvider>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
</ThemeProvider>
</SafeAreaView>
)
}
export default App
✏️ Update src/screens/RestaurantDetails/RestaurantDetails.tsx to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect } from "react"
import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import RestaurantHeader from "../../components/RestaurantHeader"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantDetailsProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantDetails"> {}
const RestaurantDetails: React.FC<RestaurantDetailsProps> = ({ route }) => {
const { slug } = route.params
const navigation = useNavigation()
const { data: restaurant, error, isPending } = useRestaurant({ slug })
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `${restaurant.name}` })
}
}, [restaurant, navigation])
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant details:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
return (
<Screen>
<RestaurantHeader restaurant={restaurant} />
<Button onPress={() => console.warn("Place an order")}>
Place an order
</Button>
{/* Exercise: Add Button that links to RestaurantOrder page. */}
</Screen>
)
}
export default RestaurantDetails
✏️ Create src/screens/RestaurantOrder/RestaurantOrder.tsx and update it to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect, useState } from "react"
import { RestaurantsStackParamList } from "../../App"
import FormSwitch from "../../components/FormSwitch"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantOrderProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantOrder"> {}
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC<RestaurantOrderProps> = ({ route }) => {
const navigation = useNavigation()
const { slug } = route.params
const { data: restaurant, error, isPending } = useRestaurant({ slug })
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `Order from ${restaurant.name}` })
}
}, [restaurant, navigation])
const handleOrder = () => {
// eslint-disable-next-line no-console
console.info("“Place My Order” button pressed!")
}
const selectedCount = Object.values(items).length
const subtotal = 0 // Exercise: Use calculateTotal here.
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant order:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
if (!restaurant) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">Restaurant not found</Typography>
</Box>
</Screen>
)
}
return (
<Screen>
<Card title="Lunch Menu">
{/* Exercise: List food items with checkboxes. */}
</Card>
<Card title="Dinner Menu">
{/* Exercise: List food items with checkboxes. */}
</Card>
<Card title="Order Details"></Card>
<Box padding="s">
{subtotal === 0 ? (
<Typography>Please choose an item.</Typography>
) : (
<Typography>{selectedCount} items selected.</Typography>
)}
</Box>
<Box padding="s">
<Typography variant="heading">Total: ${subtotal.toFixed(2)}</Typography>
</Box>
<Box padding="s">
<Button onPress={handleOrder}>Place My Order!</Button>
</Box>
</Screen>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
✏️ Create src/screens/RestaurantOrder/index.ts and update it to be:
export { default } from "./RestaurantOrder"
export * from "./RestaurantOrder"
✏️ Create src/components/FormSwitch/FormSwitch.tsx and update it to be:
import { Switch } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormSwitchProps {
label: string
value: boolean
onChange: (value: boolean) => void
}
const FormSwitch: React.FC<FormSwitchProps> = ({ label, value, onChange }) => {
const theme = useTheme()
return (
<Box
style={{
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 8,
}}
>
<Typography variant="label">{label}</Typography>
<Switch
thumbColor={theme.palette.primary.contrast}
trackColor={{
true: theme.palette.primary.strong,
false: theme.palette.screen.soft,
}}
></Switch>
</Box>
)
}
export default FormSwitch
✏️ Create src/components/FormSwitch/index.ts and update it to be:
export { default } from "./FormSwitch"
export * from "./FormSwitch"
Verify 1
✏️ Create src/screens/RestaurantOrder/RestaurantOrder.test.tsx and update it to be:
import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"
import * as restaurantHooks from "../../services/pmo/restaurant/hooks"
import RestaurantOrder from "./RestaurantOrder"
const route = {
key: "RestaurantOrder",
name: "RestaurantOrder",
params: {
slug: "bagel-restaurant",
},
} as const
describe("Screens/RestaurantOrder", () => {
// Mock the hooks and components used in RestaurantOrder
const mockRestaurantResponse = {
data: {
name: "Bagel Restaurant",
slug: "bagel-restaurant",
images: {
thumbnail: "node_modules/place-my-order-assets/images/3-thumbnail.jpg",
owner: "node_modules/place-my-order-assets/images/1-owner.jpg",
banner: "node_modules/place-my-order-assets/images/2-banner.jpg",
},
menu: {
lunch: [
{ name: "Crab Pancakes with Sorrel Syrup", price: 35.99 },
{ name: "Steamed Mussels", price: 21.99 },
{ name: "Roasted Salmon", price: 23.99 },
],
dinner: [
{ name: "Truffle Noodles", price: 14.99 },
{ name: "Spinach Fennel Watercress Ravioli", price: 35.99 },
{ name: "Herring in Lavender Dill Reduction", price: 45.99 },
],
},
address: {
street: "285 W Adams Ave",
city: "Detroit",
state: "MI",
zip: "60045",
},
coordinate: {
latitude: 0,
longitude: 0,
},
resources: {
thumbnail: "api/resources/images/3-thumbnail.jpg",
owner: "api/resources/images/4-owner.jpg",
banner: "api/resources/images/1-banner.jpg",
},
_id: "5NVE3Z5MXxX3O57R",
},
}
let useRestaurant: jest.SpyInstance<
ReturnType<typeof restaurantHooks.useRestaurant>
>
beforeEach(() => {
jest.resetAllMocks()
useRestaurant = jest.spyOn(restaurantHooks, "useRestaurant")
})
it("renders", () => {
useRestaurant.mockReturnValue({
...mockRestaurantResponse,
error: undefined,
isPending: false,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(screen.getByText(/Lunch Menu/i)).toBeOnTheScreen()
expect(
screen.getByText(mockRestaurantResponse.data.menu.lunch[0].name, {
exact: false,
}),
).toBeOnTheScreen()
expect(screen.getByText(/Dinner Menu/i)).toBeOnTheScreen()
expect(
screen.getByText(mockRestaurantResponse.data.menu.dinner[0].name, {
exact: false,
}),
).toBeOnTheScreen()
})
it("renders loading state", () => {
useRestaurant.mockReturnValue({
data: undefined,
error: undefined,
isPending: true,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
})
it("renders error state", () => {
useRestaurant.mockReturnValue({
data: undefined,
error: { name: "Oops", message: "This is the error" },
isPending: false,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(
screen.getByText(/Error loading restaurant order:/),
).toBeOnTheScreen()
expect(screen.getByText(/This is the error/)).toBeOnTheScreen()
})
})
Exercise 1
- Add
RestaurantOrder
to theNavigatorStack
. - Add a link from the
RestaurantOrder
view to theRestaurantDetails
view. - Update
FormSwitch
to pass the required props toSwitch
. - Call
useState()
and use theOrderItems
interface to create anitems
state. - Create a function for calling
setItems()
with the updateditems
state. - Update
subtotal
to use thecalculateTotal()
helper function. - List restaurant food items for both lunch and dinner menus with switches using
FormSwitchField
.
Hint: The items
state will look like this when populated:
const items = {
"Menu item 1 name": 1.23, // Menu item 1 price
"Menu item 2 name": 4.56, // Menu item 2 price
}
Solution 1
If you’ve implemented the solution correctly, the tests will pass when you run npm run test
!
Click to see the solution
✏️ Update src/App.tsx to be:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import Icon from "react-native-vector-icons/Ionicons"
import Box from "./design/Box"
import ThemeProvider, { useTheme } from "./design/theme/ThemeProvider"
import Typography from "./design/Typography"
import CityList from "./screens/CityList"
import RestaurantDetails from "./screens/RestaurantDetails"
import RestaurantList from "./screens/RestaurantList"
import RestaurantOrder from "./screens/RestaurantOrder"
import Settings from "./screens/Settings"
import StateList from "./screens/StateList"
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RootParamList extends RestaurantsStackParamList {}
}
}
export type RestaurantsStackParamList = {
StateList: undefined
CityList: {
state: {
name: string
short: string
}
}
RestaurantList: {
state: {
name: string
short: string
}
city: {
name: string
state: string
}
}
RestaurantDetails: {
slug: string
}
RestaurantOrder: {
slug: string
}
}
const RestaurantsStack = createStackNavigator<RestaurantsStackParamList>()
const RestaurantsNavigator: React.FC = () => {
return (
<RestaurantsStack.Navigator
initialRouteName="StateList"
screenOptions={{
header: ({ route, navigation }) => {
if (!navigation.canGoBack()) return null
return (
<Pressable onPress={navigation.goBack}>
<Box
padding="m"
style={{ flexDirection: "row", gap: 8, alignItems: "center" }}
>
<Icon name="arrow-back" size={20} />
<Typography variant="heading">
{/* @ts-ignore */}
{[route.params?.city?.name, route.params?.state?.name]
.filter(Boolean)
.join(", ")}
</Typography>
</Box>
</Pressable>
)
},
}}
>
<RestaurantsStack.Screen name="StateList" component={StateList} />
<RestaurantsStack.Screen name="CityList" component={CityList} />
<RestaurantsStack.Screen
name="RestaurantList"
component={RestaurantList}
/>
<RestaurantsStack.Screen
name="RestaurantDetails"
component={RestaurantDetails}
/>
<RestaurantsStack.Screen
name="RestaurantOrder"
component={RestaurantOrder}
/>
</RestaurantsStack.Navigator>
)
}
const AppTabs = createBottomTabNavigator()
const AppNavigator: React.FC = () => {
const theme = useTheme()
return (
<AppTabs.Navigator
initialRouteName="RestaurantsStack"
screenOptions={({ route }) => ({
headerStyle: {
backgroundColor: theme.palette.screen.main,
},
headerTitleStyle: {
color: theme.palette.screen.contrast,
...theme.typography.title,
},
tabBarStyle: {
backgroundColor: theme.palette.screen.main,
},
tabBarActiveTintColor: theme.palette.primary.strong,
tabBarInactiveTintColor: theme.palette.screen.contrast,
tabBarIcon: ({ focused, color }) => {
let icon = "settings"
if (route.name === "Settings") {
icon = focused ? "settings" : "settings-outline"
} else if (route.name === "Restaurants") {
icon = focused ? "restaurant" : "restaurant-outline"
}
return <Icon name={icon} size={20} color={color} />
},
})}
>
<AppTabs.Screen
name="Restaurants"
component={RestaurantsNavigator}
options={{ title: "Place My Order" }}
/>
<AppTabs.Screen
name="Settings"
component={Settings}
options={{ title: "Settings" }}
/>
</AppTabs.Navigator>
)
}
const App: React.FC = () => {
return (
<SafeAreaView style={{ flex: 1 }}>
<ThemeProvider>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
</ThemeProvider>
</SafeAreaView>
)
}
export default App
✏️ Update src/components/FormSwitch/FormSwitch.tsx to be:
import { Switch } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormSwitchProps {
label: string
value: boolean
onChange: (value: boolean) => void
}
const FormSwitch: React.FC<FormSwitchProps> = ({ label, value, onChange }) => {
const theme = useTheme()
return (
<Box
style={{
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 8,
}}
>
<Typography variant="label">{label}</Typography>
<Switch
onValueChange={onChange}
value={value}
thumbColor={theme.palette.primary.contrast}
trackColor={{
true: theme.palette.primary.strong,
false: theme.palette.screen.soft,
}}
></Switch>
</Box>
)
}
export default FormSwitch
✏️ Update src/screens/RestaurantDetails/RestaurantDetails.tsx to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect } from "react"
import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import RestaurantHeader from "../../components/RestaurantHeader"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantDetailsProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantDetails"> {}
const RestaurantDetails: React.FC<RestaurantDetailsProps> = ({ route }) => {
const { slug } = route.params
const navigation = useNavigation()
const { data: restaurant, error, isPending } = useRestaurant({ slug })
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `${restaurant.name}` })
}
}, [restaurant, navigation])
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant details:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
return (
<Screen>
<RestaurantHeader restaurant={restaurant} />
<Button
onPress={() => {
navigation.navigate("RestaurantOrder", { slug: slug })
}}
>
Place an order
</Button>
</Screen>
)
}
export default RestaurantDetails
✏️ Update src/screens/RestaurantOrder/RestaurantOrder.tsx to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect, useState } from "react"
import { RestaurantsStackParamList } from "../../App"
import FormSwitch from "../../components/FormSwitch"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantOrderProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantOrder"> {}
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC<RestaurantOrderProps> = ({ route }) => {
const navigation = useNavigation()
const { slug } = route.params
const { data: restaurant, error, isPending } = useRestaurant({ slug })
const [items, setItems] = useState<OrderItems>({})
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `Order from ${restaurant.name}` })
}
}, [restaurant, navigation])
const handleOrder = () => {
// eslint-disable-next-line no-console
console.info("“Place My Order” button pressed!")
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const selectedCount = Object.values(items).length
const subtotal = calculateTotal(items)
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant order:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
if (!restaurant) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">Restaurant not found</Typography>
</Box>
</Screen>
)
}
return (
<Screen>
<Card title="Lunch Menu">
{restaurant.menu.lunch.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Dinner Menu">
{restaurant.menu.dinner.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Order Details"></Card>
<Box padding="s">
{subtotal === 0 ? (
<Typography>Please choose an item.</Typography>
) : (
<Typography>{selectedCount} items selected.</Typography>
)}
</Box>
<Box padding="s">
<Typography variant="heading">Total: ${subtotal.toFixed(2)}</Typography>
</Box>
<Box padding="s">
<Button onPress={handleOrder}>Place My Order!</Button>
</Box>
</Screen>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Objective 2: Add text fields to collect user data
Next, we want to collect the user’s name, phone number, and address as part of the order.
To do this, we’ll use React Native’s TextInput
component.
Using the TextInput
component
For entering text, React Native uses the TextInput
component.
Changes in text input are managed using the onChangeText
prop, which receives the new text directly.
Here’s how you can use TextInput
in your application:
import { useState } from "react"
import { Text, TextInput, View } from "react-native"
const InputExample = () => {
const [text, setText] = useState("")
return (
<View>
<Text>Enter your name:</Text>
<TextInput onChangeText={(newText) => setText(newText)} value={text} />
</View>
)
}
export default InputExample
In this example, the onChangeText
prop is passed a function that calls setText
to store the text as state in the component.
Labelling text inputs
In the code above, we have a <Text>
component in the view but it does not have a programmatic relationship with the <TextInput>
itself.
This means that assistive technologies (such as screen readers) won’t know to announce the Text
when the TextInput
has focus.
To fix this, we can use the accessibility APIs:
import { useState } from "react"
import { Text, TextInput, View } from "react-native"
const InputExample = () => {
const [text, setText] = useState("")
return (
<View>
<Text nativeID={"name"}>Enter your name:</Text>
<TextInput
accessibilityLabel="input"
accessibilityLabelledBy={"name"}
onChangeText={(newText) => setText(newText)}
value={text}
/>
</View>
)
}
export default InputExample
In the code above, we add the following props:
nativeID
on theText
to give it a unique ID.accessibilityLabel
to indicate theTextInput
is an “edit box.”accessibilityLabelledBy
on theTextInput
to associate it with theText
component.
These three props combined will make the input accessible to assistive technologies.
For example, a screen reader would announce “Input, Edit Box for Enter your name” when the TextInput
receives focus.
Creating unique IDs with the useId()
Hook
There’s one minor issue with the code we have above: the nativeID
prop’s value is pretty generic ("name"
) and might accidentally be used multiple times across multiple views, causing confusion about which component is the right one to refer to!
To solve this, we can use the useId
Hook to generate a unique ID:
import { useId, useState } from "react"
import { Text, TextInput, View } from "react-native"
const InputExample = () => {
const id = useId()
const [text, setText] = useState("")
return (
<View>
<Text nativeID={id}>Enter your name:</Text>
<TextInput
accessibilityLabel="input"
accessibilityLabelledBy={id}
onChangeText={(newText) => setText(newText)}
value={text}
/>
</View>
)
}
export default InputExample
The value of useId
is guaranteed to be unique within the component where it is used.
This is ideal for linking related components together, as is the case with nativeID
and accessibilityLabelledBy
.
Setup 2
✏️ Create src/components/FormTextField/FormTextField.tsx and update it to be:
import { useId } from "react"
import { TextInput } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormTextFieldProps {
label: string
type?: "text"
value: string
onChange?: (value: string) => void
}
const FormTextField: React.FC<FormTextFieldProps> = ({
label,
value,
onChange,
}) => {
const theme = useTheme()
return (
<Box style={{ marginVertical: 8 }}>
{/* Exercise: Create Text Label and update TextInput to work with label. */}
<TextInput
style={{
flex: 1,
paddingVertical: 0,
borderBottomWidth: 1,
borderBottomColor: theme.palette.screen.contrast,
color: theme.palette.screen.contrast,
}}
/>
</Box>
)
}
export default FormTextField
✏️ Create src/components/FormTextField/index.ts and update it to be:
export { default } from "./FormTextField"
export * from "./FormTextField"
✏️ Update src/components/FormSwitch/FormSwitch.tsx to be:
import { useId } from "react"
import { Switch } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormSwitchProps {
label: string
value: boolean
onChange: (value: boolean) => void
}
const FormSwitch: React.FC<FormSwitchProps> = ({ label, value, onChange }) => {
const theme = useTheme()
return (
<Box
style={{
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 8,
}}
>
<Typography variant="label">{label}</Typography>
<Switch
onValueChange={onChange}
thumbColor={theme.palette.primary.contrast}
trackColor={{
true: theme.palette.primary.strong,
false: theme.palette.screen.soft,
}}
value={value}
></Switch>
</Box>
)
}
export default FormSwitch
✏️ Update src/screens/RestaurantOrder/RestaurantOrder.tsx to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect, useState } from "react"
import { RestaurantsStackParamList } from "../../App"
import FormSwitch from "../../components/FormSwitch"
import FormTextField from "../../components/FormTextField"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantOrderProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantOrder"> {}
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC<RestaurantOrderProps> = ({ route }) => {
const navigation = useNavigation()
const { slug } = route.params
const { data: restaurant, error, isPending } = useRestaurant({ slug })
const [items, setItems] = useState<OrderItems>({})
// Exercise: Store state for new FormTextFields in RestaurantOrder.
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `Order from ${restaurant.name}` })
}
}, [restaurant, navigation])
const handleOrder = () => {
// eslint-disable-next-line no-console
console.info("“Place My Order” button pressed!")
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const selectedCount = Object.values(items).length
const subtotal = calculateTotal(items)
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant order:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
if (!restaurant) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">Restaurant not found</Typography>
</Box>
</Screen>
)
}
return (
<Screen>
<Card title="Lunch Menu">
{restaurant.menu.lunch.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Dinner Menu">
{restaurant.menu.dinner.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Order Details">
{/* Exercise: Use name, phone, and address fields to create FormTextField elements. */}
</Card>
<Box padding="s">
{subtotal === 0 ? (
<Typography>Please choose an item.</Typography>
) : (
<Typography>{selectedCount} items selected.</Typography>
)}
</Box>
<Box padding="s">
<Typography variant="heading">Total: ${subtotal.toFixed(2)}</Typography>
</Box>
<Box padding="s">
<Button onPress={handleOrder}>Place My Order!</Button>
</Box>
</Screen>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Verify 2
✏️ Create src/components/FormTextField/FormTextField.test.tsx and update it to be:
import { render, screen, userEvent } from "@testing-library/react-native"
import FormTextField from "./FormTextField"
beforeEach(() => {
jest.resetAllMocks()
jest.useFakeTimers()
})
describe("Components/FormTextField", () => {
it("renders and handles input change", async () => {
const handleChangeMock = jest.fn()
render(
<FormTextField
label="Hello!"
onChange={handleChangeMock}
value="response"
/>,
)
const user = userEvent.setup()
expect(screen.getByText(/Hello/)).toBeTruthy()
await user.type(screen.getByLabelText(/Hello/i), "test")
expect(handleChangeMock).toHaveBeenCalledTimes(4)
expect(handleChangeMock).toHaveBeenNthCalledWith(1, "responset")
expect(handleChangeMock).toHaveBeenNthCalledWith(2, "responsee")
expect(handleChangeMock).toHaveBeenNthCalledWith(3, "responses")
expect(handleChangeMock).toHaveBeenNthCalledWith(4, "responset")
})
})
✏️ Update src/screens/RestaurantOrder/RestaurantOrder.test.tsx to be:
import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"
import * as restaurantHooks from "../../services/pmo/restaurant/hooks"
import RestaurantOrder from "./RestaurantOrder"
const route = {
key: "RestaurantOrder",
name: "RestaurantOrder",
params: {
slug: "bagel-restaurant",
},
} as const
describe("Screens/RestaurantOrder", () => {
// Mock the hooks and components used in RestaurantOrder
const mockRestaurantResponse = {
data: {
name: "Bagel Restaurant",
slug: "bagel-restaurant",
images: {
thumbnail: "node_modules/place-my-order-assets/images/3-thumbnail.jpg",
owner: "node_modules/place-my-order-assets/images/1-owner.jpg",
banner: "node_modules/place-my-order-assets/images/2-banner.jpg",
},
menu: {
lunch: [
{ name: "Crab Pancakes with Sorrel Syrup", price: 35.99 },
{ name: "Steamed Mussels", price: 21.99 },
{ name: "Roasted Salmon", price: 23.99 },
],
dinner: [
{ name: "Truffle Noodles", price: 14.99 },
{ name: "Spinach Fennel Watercress Ravioli", price: 35.99 },
{ name: "Herring in Lavender Dill Reduction", price: 45.99 },
],
},
address: {
street: "285 W Adams Ave",
city: "Detroit",
state: "MI",
zip: "60045",
},
coordinate: {
latitude: 0,
longitude: 0,
},
resources: {
thumbnail: "api/resources/images/3-thumbnail.jpg",
owner: "api/resources/images/4-owner.jpg",
banner: "api/resources/images/1-banner.jpg",
},
_id: "5NVE3Z5MXxX3O57R",
},
}
let useRestaurant: jest.SpyInstance<
ReturnType<typeof restaurantHooks.useRestaurant>
>
beforeEach(() => {
jest.resetAllMocks()
useRestaurant = jest.spyOn(restaurantHooks, "useRestaurant")
})
it("renders", () => {
useRestaurant.mockReturnValue({
...mockRestaurantResponse,
error: undefined,
isPending: false,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(screen.getByText(/Lunch Menu/i)).toBeOnTheScreen()
expect(
screen.getByText(mockRestaurantResponse.data.menu.lunch[0].name, {
exact: false,
}),
).toBeOnTheScreen()
expect(screen.getByText(/Dinner Menu/i)).toBeOnTheScreen()
expect(
screen.getByText(mockRestaurantResponse.data.menu.dinner[0].name, {
exact: false,
}),
).toBeOnTheScreen()
expect(screen.getByLabelText(/Name/i)).toBeOnTheScreen()
expect(screen.getByLabelText(/Phone/i)).toBeOnTheScreen()
expect(screen.getByLabelText(/Address/i)).toBeOnTheScreen()
})
it("renders loading state", () => {
useRestaurant.mockReturnValue({
data: undefined,
error: undefined,
isPending: true,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
})
it("renders error state", () => {
useRestaurant.mockReturnValue({
data: undefined,
error: { name: "Oops", message: "This is the error" },
isPending: false,
})
render(
<NavigationContainer>
{/* @ts-ignore */}
<RestaurantOrder route={route} />
</NavigationContainer>,
)
expect(
screen.getByText(/Error loading restaurant order:/),
).toBeOnTheScreen()
expect(screen.getByText(/This is the error/)).toBeOnTheScreen()
})
})
Exercise 2
Let’s fully implement our FormTextField
component and have it:
- Create a unique ID with
useId()
. - Use a
<Typography variant="label">
component with anativeID
. - Associate the
<Typography>
and<TextInput>
components with the correct props for accessibility. - Add the
onChangeText
andvalue
props to the<TextInput>
component.
Additionally, let’s update the FormSwitch
component to:
- Associate the
<Switch>
component with the correct props for accessibility.
Finally, let’s update the RestaurantOrder
component to:
- Have state variables and setters for
address
,phone
, andname
. - Use
<FormTextField>
to create text fields for these three state variables.
Solution 2
If you’ve implemented the solution correctly, the tests will pass when you run npm run test
!
Click to see the solution
✏️ Update src/components/FormTextField/FormTextField.tsx to be:
import { useId } from "react"
import { TextInput } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormTextFieldProps {
label: string
type?: "text"
value: string
onChange?: (value: string) => void
}
const FormTextField: React.FC<FormTextFieldProps> = ({
label,
value,
onChange,
}) => {
const theme = useTheme()
const id = useId()
return (
<Box style={{ marginVertical: 8 }}>
<Typography nativeID={id} variant="label">
{label}
</Typography>
<TextInput
accessibilityLabel="Input"
accessibilityLabelledBy={id}
onChangeText={onChange}
value={value}
style={{
flex: 1,
paddingVertical: 0,
borderBottomWidth: 1,
borderBottomColor: theme.palette.screen.contrast,
color: theme.palette.screen.contrast,
}}
/>
</Box>
)
}
export default FormTextField
✏️ Update src/components/FormSwitch/FormSwitch.tsx to be:
import { useId } from "react"
import { Switch } from "react-native"
import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"
export interface FormSwitchProps {
label: string
value: boolean
onChange: (value: boolean) => void
}
const FormSwitch: React.FC<FormSwitchProps> = ({ label, value, onChange }) => {
const theme = useTheme()
const id = useId()
return (
<Box
style={{
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 8,
}}
>
<Typography nativeID={id} variant="label">
{label}
</Typography>
<Switch
accessibilityLabel="Toggle"
accessibilityLabelledBy={id}
onValueChange={onChange}
value={value}
thumbColor={theme.palette.primary.contrast}
trackColor={{
true: theme.palette.primary.strong,
false: theme.palette.screen.soft,
}}
></Switch>
</Box>
)
}
export default FormSwitch
✏️ Update src/screens/RestaurantOrder/RestaurantOrder.tsx to be:
import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect, useState } from "react"
import { RestaurantsStackParamList } from "../../App"
import FormSwitch from "../../components/FormSwitch"
import FormTextField from "../../components/FormTextField"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"
export interface RestaurantOrderProps
extends StackScreenProps<RestaurantsStackParamList, "RestaurantOrder"> {}
type OrderItems = Record<string, number>
const RestaurantOrder: React.FC<RestaurantOrderProps> = ({ route }) => {
const navigation = useNavigation()
const { slug } = route.params
const { data: restaurant, error, isPending } = useRestaurant({ slug })
const [items, setItems] = useState<OrderItems>({})
const [name, setName] = useState<string>("")
const [phone, setPhone] = useState<string>("")
const [address, setAddress] = useState<string>("")
useEffect(() => {
if (restaurant) {
navigation.setOptions({ title: `Order from ${restaurant.name}` })
}
}, [restaurant, navigation])
const handleOrder = () => {
// eslint-disable-next-line no-console
console.info("“Place My Order” button pressed!")
}
const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
return setItems((currentItems) => {
const updatedItems = {
...currentItems,
}
if (isChecked) {
updatedItems[itemId] = itemPrice
} else {
delete updatedItems[itemId]
}
return updatedItems
})
}
const selectedCount = Object.values(items).length
const subtotal = calculateTotal(items)
if (error) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">
Error loading restaurant order:{" "}
</Typography>
<Typography variant="body">{error.message}</Typography>
</Box>
</Screen>
)
}
if (isPending) {
return <Loading />
}
if (!restaurant) {
return (
<Screen>
<Box padding="m">
<Typography variant="heading">Restaurant not found</Typography>
</Box>
</Screen>
)
}
return (
<Screen>
<Card title="Lunch Menu">
{restaurant.menu.lunch.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Dinner Menu">
{restaurant.menu.dinner.map(({ name, price }) => (
<FormSwitch
key={name}
label={`${name} ($${price})`}
onChange={(value) => setItem(name, value, price)}
value={name in items}
/>
))}
</Card>
<Card title="Order Details">
<FormTextField label="Name" onChange={setName} value={name} />
<FormTextField label="Phone" onChange={setPhone} value={phone} />
<FormTextField label="Address" onChange={setAddress} value={address} />
</Card>
<Box padding="s">
{subtotal === 0 ? (
<Typography>Please choose an item.</Typography>
) : (
<Typography>{selectedCount} items selected.</Typography>
)}
</Box>
<Box padding="s">
<Typography variant="heading">Total: ${subtotal.toFixed(2)}</Typography>
</Box>
<Box padding="s">
<Button onPress={handleOrder}>Place My Order!</Button>
</Box>
</Screen>
)
}
function calculateTotal(items: OrderItems) {
return Object.values(items).reduce((total, itemPrice) => {
return total + itemPrice
}, 0)
}
export default RestaurantOrder
Next steps
Next, in order to retain data to limit requests and migrating the same data, let’s look into Using AsyncStorage.