blog bg

June 29, 2024

Folder Structure and Reusable Components in React Native

Share what you learn in this blog to prepare for your interview, create your forever-free profile now, and explore how to monetize your valuable knowledge.

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 a Text component. Note that only Text 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 the InstructionText 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

278 views

Please Login to create a Question