June 29, 2024
Folder Structure and Reusable Components in React Native
Folder Structure and Reusable Components in React Native
In this blog post, we will explore how to build reusable components and establish best practices for folder structure in a React Native project. We'll also build a Guessing Game application to illustrate these concepts. The design and code for this post are based on a course I took on Udemy, and I'm excited to share what I've learned with you!
Introduction
Building an application is similar to assembling a structure from building blocks. Unlike a real building, similar blocks in a React Native application can be reused throughout the "house." When creating a UI, components like buttons and cards are used repeatedly. Instead of copying and pasting code, a bad practice, reusable components allow us to reuse code and pass different data through props to be rendered on the UI. Let's dive into today's application.
We'll create a Guessing Game application where the user selects a number, and the application tries to guess it. If the app guesses correctly, the game ends; otherwise, the application guesses again. See how the app works here
Setting Up the Application
First, let's install React Native and other dependencies.
npx create-expo-app -t expo-template-blank-typescript
Next, install the necessary dependencies:
npx expo install expo-app-loading
npx expo install expo-font
npx expo install expo-linear-gradient
- expo-app-loading
: Displays a loading screen when the app is in a loading state.
- expo-font
: Allows the use of custom fonts.
- expo-linear-gradient
: Provides an easy way to implement background linear gradients.
Run npm start
to set up your app.
Folder Structure
We'll create three main folders in the root directory of our application:
1. screens
: Contains the different pages of the app.
2. components
: Holds reusable components.
3. constants
: Stores fixed variables and values to be reused.
We'll build our application from the ground up, starting with constants, components, and screens, and then putting them all together in the App.tsx
file.
Constants
Navigate to your constants
folder and create a file colors.ts
. Here, we'll define the colors used throughout the application.
const Colors = {
primary500: '#72063c',
primary600: '#640233',
primary700: '#4e0329',
primary800: '#3b021f',
accent500: '#ddb52f',
};
export default Colors;
Components
Next, we'll move to our components
directory and create three subfolders: Buttons
, Game
, and Ui
. Each folder serves a specific purpose:
- Buttons
: Contains reusable button components.
- Game
: Contains game-specific reusable components.
- Ui
: Contains other general reusable components.
Button Component
In the Buttons
folder, create a PrimaryButton.tsx
file:
import { ReactNode } from "react";
import { Text, View, Pressable, StyleSheet, GestureResponderEvent } from "react-native";
import Colors from "../../constants/colors";
type Props = {
children: ReactNode;
onPress: (event: GestureResponderEvent) => void;
};
function PrimaryButton({ children, onPress }: Props) {
return (
<View style={styles.buttonOuterContainer}>
<Pressable
style={({ pressed }) =>
pressed
? [styles.buttonInnerContainer, styles.pressed]
: styles.buttonInnerContainer
}
onPress={onPress}
android_ripple={{ color: Colors.primary600 }}
>
<Text style={styles.buttonText}>{children}</Text>
</Pressable>
</View>
);
}
export default PrimaryButton;
You can find the style object in my GitHub repository. The PrimaryButton
component mimics the React Native Button component, but because we can't apply custom styling to the native button, we're creating our own.
Notice how our button is wrapped in a View component with the text rendered using the Text component, similar to React Native's button. However, we use the Pressable component provided by Expo because we need to handle press events. The component accepts two props: children
, which will be the content of the button, and onPress
, which handles the event. This makes our button reusable across the application.
Key Points About Pressable
Android Ripple Effect: The Pressable component takes an android_ripple
prop, which gives ripple feedback when the button is pressed. This is only available on Android.
iOS Customization: For iOS, a bit of customization is required. The style prop can also accept a function and an array of styles. Here, we pass in a function that accepts an object. When destructured, we get a boolean value pressed, which is true when the button is pressed and false otherwise. So when the button is pressed, we return an array of styles of the original style and the on-press style.
In future posts, we'll learn how to better target specific platforms for styling. For now, this approach will suffice.
UI Components
In the Ui
directory, create a Card.tsx
file for a reusable card container:
import React, { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import Colors from "../../constants/colors";
type Props = {
children: ReactNode;
};
const Card = ({ children }: Props) => {
return <View style={styles.card}>{children}</View>;
};
export default Card;
The Card
component wraps its children with a styled View
, making it easy to reuse.
Create an InstructionText.tsx
component for displaying instructions:
import React from "react";
import { StyleProp, StyleSheet, Text, TextStyle } from "react-native";
import Colors from "../../constants/colors";
type Props = {
children: string;
style?: StyleProp<TextStyle>;
};
const InstructionText = ({ children, style }: Props) => {
return <Text style={[styles.instructionText, style]}>{children}</Text>;
};
export default InstructionText;
const styles = StyleSheet.create({
instructionText: {
color: Colors.accent500,
fontFamily: "open-sans",
fontSize: 24,
},
});
The InstructionText
component is used to display screen instructions. Here's a breakdown of how it works:
- Children Prop: The
children
prop is wrapped around by aText
component. Note that onlyText
components or strings can be passed in as children. - Style Prop: The
style
prop is passed in as an array, with the component style as the first index and an optional style prop as the second index. This allows theInstructionText
styles to be overridden to suit the components where it is needed. This customization capability is a key feature for reusable components.
Create a Title.tsx
file for screen titles:
import React, { ReactNode } from "react";
import { StyleSheet, Text } from "react-native";
type Props = {
children: ReactNode;
};
function Title({ children }: Props) {
return <Text style={styles.title}>{children}</Text>;
}
export default Title;
const styles = StyleSheet.create({
title: {
fontFamily: "open-sans-bold",
fontSize: 24,
color: "white",
textAlign: "center",
borderWidth: 2,
borderColor: "white",
padding: 12,
},
});
Game Components
In the Game
directory, create a GuessLogItem.tsx
file to log the guess history:
import React from "react";
import { Text, View, StyleSheet } from "react-native";
import Colors from "../../constants/colors";
type Props = {
roundNumber: number;
guess: number;
};
function GuessLogItem({ roundNumber, guess }: Props) {
return (
<View style={styles.listItem}>
<Text style={styles.itemText}>#{roundNumber}</Text>
<Text style={styles.itemText}>Opponent's Guess: {guess}</Text>
</View>
);
}
export default GuessLogItem;
This component logs each guess attempt by the game, displaying the round number and the guess.
Create a NumberContainer.tsx
file to display the game's guess number:
import React, { ReactNode } from "react";
import { Text, View, StyleSheet } from "react-native";
import Colors from "../../constants/colors";
type Props = {
children: ReactNode;
};
const NumberContainer = ({ children }: Props) => {
return (
<View style={styles.container}>
<Text style={styles.numberText}>{children}</Text>
</View>
);
};
export default NumberContainer;
Screens
In the screens
directory, create a StartGameScreen.tsx
file. This is where we will start the game:
import React, { useState } from "react";
import { StyleSheet, TextInput, View, Alert, Text } from "react-native";
// Custom Imports
import Title from "../components/Ui/Title";
import Colors from "../constants/colors";
import Card from "../components/Ui/Card";
import InstructionText from "../components/Ui/InstructionText";
import PrimaryButton from "../components/Buttons/PrimaryButton";
type Props = {
onPickNumber: (num: number) => void;
};
const StartGameScreen = ({ onPickNumber }: Props) => {
// Local state to manage the user's entered number input text field
const [enteredNumber, setEnteredNumber] = useState("");
// Handler Function to manage the key stroke events when the user types in a number
function numberInputHandler(input: string) {
setEnteredNumber(input);
}
// Handler function to confirm and pick a number.
function confirmInputHandler() {
// Entered Number will be a string - Every number or text from an input will always be a string
// So we parse it
const chosenNumber = parseInt(enteredNumber);
// Check if the parsed number is not a number and the number is not within our guess range i.e 0 - 99
if (isNaN(chosenNumber) || chosenNumber <= 0 || chosenNumber > 99) {
// Show an alert popup when the
Alert.alert(
"Invalid number!",
"Number has to be a number between 1 and 99.",
[{ text: "Okay", style: "destructive", onPress: resetInputHandler }]
);
return;
}
// Fire the onPickNumber function
onPickNumber(chosenNumber);
}
// Resets the Local state
function resetInputHandler() {
setEnteredNumber("");
}
return (
<View style={styles.screenContainer}>
<Title>Guess My Number</Title>
<Card>
<InstructionText>Enter a number</InstructionText>
<TextInput
style={styles.numberInput}
maxLength={2}
keyboardType="number-pad"
autoCapitalize="none"
autoCorrect={false}
value={enteredNumber}
onChangeText={numberInputHandler}
/>
<View style={styles.buttonsContainer}>
<View style={styles.buttonContainer}>
<PrimaryButton onPress={resetInputHandler}>Reset</PrimaryButton>
</View>
<View style={styles.buttonContainer}>
<PrimaryButton onPress={confirmInputHandler}>Confirm</PrimaryButton>
</View>
</View>
</Card>
</View>
);
};
export default StartGameScreen;
This component manages the initial screen of a number guessing game in React Native. It enables user input through a TextInput
component configured with a number-pad
keyboard type, restricting input to numeric characters only. The maxLength
property is set to 2, limiting input to numbers between 1 and 99. The value
of the TextInput
is synchronized with the local state enteredNumber
, ensuring real-time updates as the user types. The onChangeText
prop is wired to numberInputHandler
, which updates enteredNumber
with each keystroke. Key UI elements like Title
, Card
, InstructionText
, and PrimaryButton
are used to create a user-friendly interface design.
Create a GameScreen.tsx
file. This is where the game is played:
import React, { useEffect, useState } from "react";
import { View, StyleSheet, Alert, FlatList } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Title from "../components/Ui/Title";
import Card from "../components/Ui/Card";
import InstructionText from "../components/Ui/InstructionText";
import PrimaryButton from "../components/Buttons/PrimaryButton";
import NumberContainer from "../components/Game/NumberContainer";
import GuessLogItem from "../components/Game/GuessLogItem";
type Props = {
userNumber: number;
onGameOver: (numberOfRounds: number) => void;
};
/**
* Generate a Random number between a Min and Max Number recursively,
* recalling the function if the excluded number is generated.
*
* @param min Minimum number that can be generated
* @param max Maximum number that can be generated
* @param exclude Number to be excluded
* @returns Generated number
*/
function generateRandomBetween(min: number, max: number, exclude: number) {
const rndNum = Math.floor(Math.random() * (max - min)) + min;
if (rndNum === exclude) {
return generateRandomBetween(min, max, exclude);
} else {
return rndNum;
}
}
let minBoundary = 1;
let maxBoundary = 100;
function GameScreen(this: any, { userNumber, onGameOver }: Props) {
// The initial Game guess
const initialGuess = generateRandomBetween(1, 100, userNumber); // Exclude the user's input so the game does not guess it on first try.
const [currentGuess, setCurrentGuess] = useState(initialGuess);
const [guessRounds, setGuessRounds] = useState([initialGuess]);
/**
* Listening to end the game.
* When the current game guess is equal to the user selected number, end the game.
*/
useEffect(() => {
if (currentGuess === userNumber) {
onGameOver(guessRounds.length);
}
}, [currentGuess, userNumber, onGameOver]);
useEffect(() => {
minBoundary = 1;
maxBoundary = 100;
}, []);
/**
* Handles the next guess direction and updates the guessing range accordingly.
* Alerts the user if the direction is logically incorrect based on the current guess.
* Generates a new guess within the updated range and sets it as the current guess.
*
* @param direction - The direction to adjust the next guess.
* - "lower" indicates the next guess should be lower than the current guess,
* - "greater" indicates it should be higher.
* @returns void
*/
function nextGuessHandler(direction: "lower" | "greater") {
const goLower = direction === "lower" && currentGuess < userNumber;
const goGreater = direction === "greater" && currentGuess > userNumber;
// Check if the provided direction is logically incorrect
if (goLower || goGreater) {
Alert.alert("Don't Lie!", "You know that this is wrong...", [
{ text: "Sorry!", style: "cancel" },
]);
return;
}
// Update the guessing boundaries based on the direction
if (direction === "lower") {
maxBoundary = currentGuess - 1;
} else {
minBoundary = currentGuess + 1;
}
// Generate a new random number within the updated boundaries
const newRanNum = generateRandomBetween(
minBoundary,
maxBoundary,
currentGuess
);
// Set the new random number as the current guess
setCurrentGuess(newRanNum);
setGuessRounds((prev) => [newRanNum, ...prev]);
}
const guessRoundsListLength = guessRounds.length;
return (
<View style={styles.screen}>
<Title>Opponent's Guess</Title>
<NumberContainer>{currentGuess}</NumberContainer>
<Card>
<InstructionText style={styles.instructionText}>
Higher or Lower?
</InstructionText>
<View style={styles.buttonsContainter}>
<View style={styles.buttonContainer}>
<PrimaryButton onPress={nextGuessHandler.bind(this, "lower")}>
<Ionicons name="remove" size={24} color={"white"} />
</PrimaryButton>
</View>
<View style={styles.buttonContainer}>
<PrimaryButton onPress={nextGuessHandler.bind(this, "greater")}>
<Ionicons name="add" size={24} color={"white"} />
</PrimaryButton>
</View>
</View>
</Card>
<View style={styles.listContainer}>
<FlatList
data={guessRounds}
renderItem={({ item, index }) => (
<GuessLogItem
roundNumber={guessRoundsListLength - index}
guess={item}
/>
)}
keyExtractor={(item) => item.toString()}
/>
</View>
</View>
);
}
export default GameScreen;
This component represents the main game screen of a number guessing game in React Native. It utilizes several custom components such as Title
, Card
, InstructionText
, PrimaryButton
, NumberContainer
, and GuessLogItem
to construct the user interface.
The game starts by generating an initial guess using the generateRandomBetween
function, which excludes the user's selected number to prevent an immediate correct guess. The current guess and the history of guesses are managed through state hooks (useState
).
The component employs useEffect
hooks to monitor changes in currentGuess
and userNumber
. When the currentGuess
matches the userNumber
, indicating a correct guess, the onGameOver
function is triggered with the number of rounds played.
To guide the user, the screen displays the current guess within a NumberContainer
component. Users are prompted to indicate whether the next guess should be "higher" or "lower" through PrimaryButton
components decorated with icons (Ionicons
).
Incorrect directions trigger an Alert
informing the user of the error. Subsequently, the guessing range (minBoundary
and maxBoundary
) is adjusted based on the user's feedback, and a new guess is generated within the updated boundaries.
The game's progress, including each guess and its corresponding round number, is displayed in a FlatList
component using GuessLogItem
. This list provides a chronological log of all guesses made during the game session.
Overall, GameScreen
encapsulates the game logic and UI elements necessary for an interactive and engaging number-guessing experience in React Native.
Create a GameOverScreen.tsx
file
import React from "react";
import { Image, StyleSheet, Text, View } from "react-native";
import Title from "../components/Ui/Title";
import Colors from "../constants/colors";
import PrimaryButton from "../components/Buttons/PrimaryButton";
type Props = {
roundsNumber: number;
userNumber: number;
onStartNewGame: () => void;
};
const GameOverScreen = ({roundsNumber, userNumber, onStartNewGame}: Props) => {
return (
<View style={styles.screenContainer}>
<Title>GAME OVER!</Title>
<View style={styles.imageContainer}>
<Image
style={styles.image}
source={require("../assets/images/success.png")}
/>
</View>
<Text style={styles.summaryText}>
Your phone needed <Text style={styles.highlight}>{roundsNumber}</Text> rounds to
guess the number <Text style={styles.highlight}>{userNumber}</Text>
</Text>
<PrimaryButton onPress={onStartNewGame}>Start New Game</PrimaryButton>
</View>
);
};
export default GameOverScreen;
This component represents the screen displayed when the game concludes, by correctly guessing the user's number. It utilizes custom components such as Title
, PrimaryButton
, and React Native's Image
and Text
components to present the game outcome and provide options for the user.
Upon rendering, the screen prominently features a Title
component with the text "GAME OVER!". An image, sourced from success.png
in the application's assets, is displayed within an Image
component to visually indicate the successful conclusion of the game.
The component further communicates the game's outcome through a Text
component, which details the number of rounds (roundsNumber
) taken by the application to correctly guess the user's number (userNumber
). The highlight
style is applied to emphasize these details within the text.
To enable the user to start a new game, a PrimaryButton
component labeled "Start New Game" is provided. Pressing this button triggers the onStartNewGame
function prop, which navigates the user to the StartGameScreen
.
Overall, GameOverScreen
encapsulates the visual presentation and user interaction elements necessary to convey the outcome of the number-guessing game and initiate a fresh game session in React Native.
Finally, we put everything together in the App.tsx
file
import { useState } from "react";
import { useFonts } from "expo-font";
import AppLoading from "expo-app-loading";
import { LinearGradient } from "expo-linear-gradient";
import { StyleSheet, ImageBackground, SafeAreaView } from "react-native";
import Colors from "./constants/colors";
import GameScreen from "./screens/GameScreen";
import GameOverScreen from "./screens/GameOverScreen";
import StartGameScreen from "./screens/StartGameScreen";
export default function App() {
// Tracks game state. True when the user starts the game - the default,
// then false when game is over - The game guessed the right number
const [gameIsOver, setGameIsOver] = useState(true);
// The number of attempts the game took to guess the correct user number
const [guessRounds, setGuessRounds] = useState(0);
// Tracks the user chosen number
const [userNumber, setUserNumber] = useState<number | null>();
// Load the custom fonts
const [fontsLoaded] = useFonts({
"open-sans": require("./assets/fonts/OpenSans-Regular.ttf"),
"open-bold": require("./assets/fonts/OpenSans-Bold.ttf"),
});
// Show Loading state when Fonts is Loading
if (!fontsLoaded) {
<AppLoading />;
}
// Handles the event for when the user submits a number
function pickedNumberHandler(pickedNumber: number) {
// Set the number in state
setUserNumber(pickedNumber);
// The Game starts so gameIsOver = false;
setGameIsOver(false);
}
// Fires when the game is over - The Game guesses the user's number right
function gameOverHandler(numberOfRounds: number) {
setGameIsOver(true);
setGuessRounds(numberOfRounds);
}
/**
* Fires when user clicks on start new game
* Restarts by setting game is over state to false.
* It resets the user number to default - `null`
* Rests Guess rounds back to zero.
*/
function startNewGameHandler() {
setGameIsOver(false);
setUserNumber(null);
setGuessRounds(0);
}
// Default screen
let screen = <StartGameScreen onPickNumber={pickedNumberHandler} />;
// If the user selects a number, move the user to the Game screen - Start the game.
if (userNumber) {
screen = (
<GameScreen onGameOver={gameOverHandler} userNumber={userNumber} />
);
}
/**
* The Game is over when the `gameOverHandler` function is fired and the user has a number
* i.e The user is done playing
*/
if (gameIsOver && userNumber) {
screen = (
<GameOverScreen
userNumber={userNumber}
roundsNumber={guessRounds}
onStartNewGame={startNewGameHandler}
/>
);
}
return (
<LinearGradient
colors={[Colors.primary700, Colors.accent500]}
style={styles.rootScreen}
>
<ImageBackground
source={require("./assets/images/background.png")}
resizeMode="cover"
style={styles.rootScreen}
imageStyle={styles.backgroundImage}
>
<SafeAreaView style={styles.rootScreen}>{screen}</SafeAreaView>
</ImageBackground>
</LinearGradient>
);
}
const styles = StyleSheet.create({
rootScreen: {
flex: 1,
},
backgroundImage: {
opacity: 0.15,
},
});
The App.tsx
file orchestrates the main logic and presentation of the application:
State Management: It utilizes useState
from React to manage several key pieces of state:
- gameIsOver
: Tracks whether the game is currently ongoing or if it has ended.
- guessRounds
: Keeps count of the number of rounds (attempts) taken by the game to guess the user's number.
- userNumber
: Stores the number chosen by the user to be guessed by the game.
Font Loading: Custom fonts (`"open-sans"` and `"open-bold"`) are loaded using the useFonts
hook from Expo. This ensures that the fonts are available before rendering any text in the application.
Conditional Rendering: The main content of the application is dynamically rendered based on the current state:
- Initially, the StartGameScreen
is displayed where the user can input a number.
- Once the user picks a number, the GameScreen
is shown where the game attempts to guess the user's number.
- When the game successfully guesses the user's number or the user decides to start a new game, the GameOverScreen
is displayed showing the game's performance.
Event Handlers:
- pickedNumberHandler
: Handles the user selecting a number, updating userNumber
in the state, and setting gameIsOver
to false
to start the game.
- gameOverHandler
: Manages the game-ending scenario, updating gameIsOver
to true
and storing the number of rounds taken to guess the user's number in guessRounds
.
- startNewGameHandler
: Resets the game state (gameIsOver
, userNumber
, guessRounds
) to allow the user to start a new game.
Styling:
- The application uses LinearGradient
from Expo to apply a gradient background, using colors defined in the Colors
constant.
- An ImageBackground
wraps the entire application, displaying a background image that enhances the visual appeal of the app.
Overall Functionality:
The file effectively manages transitions between different screens based on user input and game state, ensuring a user experience. This approach to screen transition is sufficient for this application. However, in future posts, we will discuss how to properly implement screen navigation.
Conclusion
With these reusable components and a structured folder hierarchy, we have laid the foundation for a scalable and maintainable React Native application. In future posts, we'll delve deeper into specific components and their styling, as well as how to target specific platforms for better customization as well as screen navigations.
Github Repo: https://github.com/Intuneteq/guessing-game
280 views