Jan Hesters

Sit Shotgun With A Senior To TDD An Expo React Native App Using Redux

One of the best ways to learn if you are a developer is by having a mentor.

I'm Jan, I'm the CTO of ReactSquad and in this video, you get to sit shotgun and build the app that you can see on screen right now together with me.


Note to Cheta or Ibrahim: Record the whole flow of the app including asking for permissions, etc. at the END of this tutorial, so we can show it here. Remember, you can record the iOS device through the simulator (button on the top right of the simulator).

Make sure you're using the latest version of Expo and let me know if you needed to upgrade (this tutorial is still written for Expo SDK 51).

Also, this script has many notes like this one. During and after recording the video, double check that you took all the notes into account.

Furthermore, when you record the code snippets: relax.

Reveal something. Pause. Scroll. Pause. Reveal the next thing. Pause. Etc.

And read the script to plan ahead. Some of these code snippets are long. So make sure that you avoid cutting off important parts. E.g. make sure the imports are fully shown, the createProps function is visible in full, each test case is visible in full, etc.

We can always speed up your recordings in post-production, but if they're too hasty or missing something, you'll have to redo them. This is the most ambitious YT-video, yet, so let's make sure it turns out great.

Make sure you get the positioning right in the long code snippets. I always used comments to indicate where the code snippet starts and ends.

Lastly, given how long this tutorial is, I might have made some mistakes. If you catch any, let me know via Slack, so we can update the script.


You get to watch a senior developer do test-driven development in React. You get to see how a senior developer structures their code. You get to see how a senior developer uses function composition. And at the very end of the video, you learn how you can use sagas to isolate random side effects, in this case scheduling notifications.

Even if you are a senior developer yourself, this video is jam-packed with knowledge and little nuggets that you get to steal to use in your own code.

Even though this video can be watched on its own as a standalone video and you can learn a lot from it, it does use Redux. And this is actually video 4 in a 5-part series on Redux.

If you get stuck at some point as a new developer, go watch parts 1 to 3 first and then resume wherever you got stuck because you do need to understand Redux to understand what's going on in this video. I'm going to mention this throughout the video to give you pointers what to watch as prerequisites, but this video is definitely NOT for you if you're new to React, or Redux.

If you're familiar with React but new to React Native, that's okay - you can still follow along. If you feel uncomfortable, a quick React Native crash course might help.

// TODO: thoroughly dsecribe the Loom recording of the app.

So, without further ado, let's get started!

Create The App

Start by setting up your project using Expo.

Open your temrminal and run the create-expo-app command.

$ npx create-expo-app@latest            
 What is your app named? real-world-redux
 Downloaded and extracted project files.
> npm install
 
...
 
 Your project is ready!
 
To run your project, navigate to the directory and run one of the following npm commands.
 
- cd real-world-redux
- npm run android
- npm run ios
- npm run web

Next, navigate into your project directory and open it in your favorite code editor.

$ cd real-world-redux && code .

Note to Cheta & Ibrahim: Show & "explore" the app/ folder a bit because it'll be deleted in the next step. Show the folders in the side bar, open some files, scroll through some code (slowly), etc.


Make Sure Expo Works


Note to Cheta or Ibrahim: Show a recording of each of the steps here, e.g. show the QR code in the terminal, show the developer menu in the simulator, etc.


Make sure that your Expo environment is working correctly.

$ npm start

If you want to run the app on a physical device, use the Expo Go app and scan the QR code displayed in your terminal.

Alternatively, you can run it on a simulator:

  • Press i to open the iOS simulator.
  • Press a to open the Android emulator.

If you encounter any issues running the app on a device or simulator, you can run it in your web browser by accessing http://localhost:8081.

In the simulator, you can press r to reload the app and m to open the developer menu.

Remove Expo Boilerplate

When you create an Expo project, it is created in TypeScript. But this tutorial is going to use JavaScript instead. In the 5th part of this series, you're going to create a TypeScript app with Redux.

New Expo apps also come with a bunch of boilerplate code that you can read to familiarize yourself with React Native apps. At this point, if you're new to React Native, take a moment to explore the app/ folder that Expo has generated.

You can get rid of this boilerplate code by running the "reset-project" script.

$ npm run reset-project && rm -rf ./app-example && rm -rf ./scripts && rm -rf ./components && rm -rf ./constants && rm -rf ./hooks

This command moves the files from the "app" folder to "app-example," then creates a new "app" folder with a new "index.tsx" file. You then delete the app-example/ folder and the scripts/ folder, as well the components, hooks and constants folders.

Code Formatting

To ensure your code stays clean and consistent, you'll set up ESLint and Prettier. Install Prettier, ESLint, and a variety of plugins such as unicorn, import, prettier, simple-import-sort, and the expo config.

$ npm install --save-dev prettier eslint@8 eslint-plugin-unicorn eslint-plugin-import eslint-plugin-prettier eslint-config-prettier eslint-plugin-simple-import-sort eslint-config-expo

Create a prettier.config.js file with the following content:

prettier.config.js
/* eslint-disable unicorn/prefer-module */
module.exports = {
  arrowParens: 'avoid',
  bracketSameLine: false,
  bracketSpacing: true,
  htmlWhitespaceSensitivity: 'css',
  insertPragma: false,
  jsxSingleQuote: false,
  plugins: [],
  printWidth: 80,
  proseWrap: 'always',
  quoteProps: 'as-needed',
  requirePragma: false,
  semi: true,
  singleQuote: true,
  tabWidth: 2,
  trailingComma: 'all',
  useTabs: false,
};

Also, create an .eslintrc.js file:

.eslintrc.js
/* eslint-disable unicorn/prefer-module */
module.exports = {
  extends: [
    'expo',
    'plugin:unicorn/recommended',
    'plugin:import/errors',
    'plugin:import/warnings',
    'prettier',
  ],
  plugins: ['prettier', 'simple-import-sort'],
  rules: {
    'prettier/prettier': 'error',
    'unicorn/filename-case': [
      'error',
      {
        case: 'kebabCase',
      },
    ],
    'unicorn/no-array-reduce': 'off',
    'unicorn/no-array-callback-reference': 'off',
    'unicorn/no-null': 'off',
    'unicorn/prevent-abbreviations': [
      'error',
      {
        replacements: {
          props: false,
          params: false,
        },
      },
    ],
    'simple-import-sort/imports': 'error',
    'simple-import-sort/exports': 'error',
    'import/order': 'off',
  },
  overrides: [
    {
      files: ['src/tests/**/*.js', 'src/**/*.test.js'],
      env: {
        jest: true,
      },
    },
  ],
};

This configuration sets up ESLint to work with Prettier and Expo. It includes several plugins and rules to sort your imports and enforce a certain opinionated coding style through the unicorn plugin.

If you want to learn more, you can look up the respective plugins and configs online to see what they do.

Folder Structure

Create a src/ directory for your source code as the parent of the app/ directory.

Furthermore, you're going to group your files by feature.

When you group files by feature in a project, you organize all related components, reducers, and tests together, which makes it easy to manage and modify each feature.

.  
├── onboarding/
│ ├── component
│ ├── reducer
│ └── test
└── reminders/
    ├── component
    ├── reducer
    └── test

After you've moved the app/ directory into src/ directory run your app again and verify that you did not break anything.

Install & Configure Testing Tools

To set up testing for your app, install React Native Testing Library:

npx expo install -- --save-dev @testing-library/react-native

Also, install additional testing utilities for Redux Saga and to generate fake data:

npm i --save-dev redux-saga-test-plan @faker-js/faker

You don't need react-test-renderer for this project, so remove it:

npm uninstall react-test-renderer @types/react-test-renderer

In your package.json, add the Jest configuration:

package.json
{
  // ...
  "jest": {
    "preset": "jest-expo",
    "setupFilesAfterEnv": ["<rootDir>/src/tests/setup-jest.js"],
    "transformIgnorePatterns": [
      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rneui)"
    ]
  },
  // ...
}

The "preset" property tells Jest to use the jest-expo preset, which is a configuration that Jest uses to run tests in a React Native environment.

The "setupFilesAfterEnv" property tells Jest to use the src/tests/setup-jest.js file as a setup file for your tests.

The "transformIgnorePatterns" property tells Jest to ignore certain node modules that are not compatible with Jest.

Next, create the Jest setup file src/tests/setup-jest.js:

src/tests/setup-jest.js
import "@testing-library/react-native/extend-expect";
import "react-native-gesture-handler/jestSetup";
 
import mockSafeAreaContext from "react-native-safe-area-context/jest/mock";
 
jest.mock("react-native-safe-area-context", () => mockSafeAreaContext);

Install Redux And Other Deps

Install Redux, React Redux, middleware, Ramda for functional programming, date-fns for date manipulation, RNEUI for styling, the async storage package and cuid2 for unique IDs.


Note to Cheta or Ibrahim: After you installed these dependencies, you might need to manually fix them because at the time of writing this tutorial, some dependencies were not compatible with each other and I got this error:

The following packages should be updated for best compatibility with the installed expo version:
  @react-native-async-storage/async-storage@2.0.0 - expected version: 1.23.1
  expo@51.0.37 - expected version: ~51.0.38

You can always peek in our GH repo to find the dependencies I've used during the writing of this tutorial.


npm i --save @react-native-async-storage/async-storage @reduxjs/toolkit redux-persist redux-saga ramda @rneui/themed @rneui/base @paralleldrive/cuid2 react-redux date-fns

Style

Define your color palette in src/styles/colors.js:

src/styles/colors.js
export const darkColors = {
  backgroundColorPrimary: '#111827',
  backgroundColorSecondary: '#1f2937',
  backgroundColorTertiary: 'rgba(55, 65, 81, 0.1)',
  secondary: 'rgba(255, 255, 255, 0.1)',
  primaryColor: '#6366f1',
  primaryColorLight: '#818cf8',
  textPrimary: '#ffffff',
  textSecondary: '#d1d5db',
  borderColor: 'rgba(255, 255, 255, 0.05)',
};

You can tweak these to fit your desired color scheme and make the app your own.

Then, create a theme using these colors in src/styles/themes.js:

src/styles/themes.js
import { createTheme } from '@rneui/themed';
 
import { darkColors } from './colors';
 
export const darkTheme = createTheme({
  components: {
    Button: {
      activeOpacity: 0.8,
      buttonStyle: {
        backgroundColor: darkColors.primaryColor,
        borderRadius: 6,
      },
      disabledStyle: {
        backgroundColor: darkColors.primaryColor,
        opacity: 0.5,
      },
    },
    Text: {
      style: {
        color: darkColors.textPrimary,
      },
    },
    Slider: {
      maximumTrackTintColor: darkColors.borderColor,
      minimumTrackTintColor: darkColors.primaryColorLight,
      thumbTintColor: darkColors.primaryColor,
    },
  },
});

Expo has a file-based routing system similar to Next.js. Place a route inside a folder named after the route. The index.js file in that folder becomes the screen for that route.

Files named _layout.js include shared elements for all nested routes. They must contain one <Slot /> component that renders the child route.

You can use the group syntax () to prevent a segment from showing in the URL.

  • app/main/home.js matches /main/home.
  • app/main/_layout.js wraps around /main/home.
  • app/(main)/home.js matches /home.
  • app/(main)/_layout.js wraps around /home, but NOT /main/home.
  • app/_layout.js wraps around everything (/main/home and /home).

This is useful because assume you want to have two routes: /home and /about.

Now you want to have different layouts for each of these routes. If you'd just create the following 3 files:

  • app/_layout.js
  • app/home.js
  • app/about.js

In this case, home and about would share the same layout.

If you want to have different layouts for each of these routes, you need to create the following files:

  • app/(home)/_layout.js
  • app/(home)/home.js
  • app/(about)/_layout.js
  • app/(about)/about.js

Another possibility would be to structure your routes like this:

  • app/home/_layout.js
  • app/home/index.js
  • app/about/_layout.js
  • app/about/index.js

You want to access your theme throughout your entire app. So, rename src/app/_layout.tsx to src/app/_layout.js and wrap a ThemeProvider around your app in your root layout.

src/app/_layout.js
import { ThemeProvider } from '@rneui/themed';
import { Slot } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
 
import { darkTheme } from '../styles/themes';
 
export default function Layout() {
  return (
    <ThemeProvider theme={darkTheme}>
      <StatusBar style="light" />
      <Slot />
    </ThemeProvider>
  );
}

Import the ThemeProvider from React Native Elements, and pass your darkTheme to it. Then wrap it around your status bar and slot.

Assets

If you want to, you can personalize your app with a custom icon and splash screen. The icon represents your app on your phone, while the splash screen appears when your app is launching.

Expo provides a default icon and splash screen, but you can also use your own. To use custom images, you can either download adaptive-icon.png, icon.png, and splash.png from the tutorial's GitHub repository or create your own. If you choose to download, save these files in your assets/images/ folder.

Ensure that the paths in your app.json correctly point to these image files.

{
  "expo": {
    "android": {
      "adaptiveIcon": {
        "backgroundColor": "#111827",
        "foregroundImage": "./assets/images/adaptive-icon.png"
      }
    },
    "experiments": {
      "typedRoutes": true
    },
    "icon": "./assets/images/icon.png",
    "ios": {
      "supportsTablet": true
    },
    "name": "real-world-redux",
    "orientation": "portrait",
    "plugins": [
      "expo-router"
    ],
    "scheme": "real-world-redux",
    "slug": "real-world-redux",
    "splash": {
      "backgroundColor": "#111827",
      "image": "./assets/images/splash.png",
      "resizeMode": "contain"
    },
    "userInterfaceStyle": "dark",
    "version": "1.0.0",
    "web": {
      "bundler": "metro",
      "favicon": "./assets/images/favicon.png",
      "output": "static"
    }
  }
}

Configure the backgroundColor for your adaptive icon and your splash screen to be #111827 to match the backgroundColorPrimary color in your theme.

And hardcode the user interface style to dark.

Lastly, verify that your "name", "slug", and "scheme" are set to "real-world-redux".


Note to Cheta or Ibrahim: Record reopening the app here after configuring the assets and show the app icon and the new splash screen.


On your phone, your app has now a new icon. And when you open or reload your app, you see the new splash screen.

Set Up Helpers

You're going to use Test-Driven Development (TDD) throughout building this app.

The first helper you'll write is asyncPipe. Create a file src/utils/async-pipe.js and start with an empty function:

src/utils/async-pipe.js
export const asyncPipe = () => () => 0;

When you use TDD, you always start by writing an empty function, component, or a 'do nothing' version of the code you want to test first. This approach lets you write failing tests against a placeholder implementation, rather than seeing your tests fail simply because the thing you're testing can't be imported as it doesn't exist yet.

Write the tests for this function. Create src/utils/async-pipe.test.js adjacent to the src/utils/async-pipe.js file because remember, you're grouping by feature, which means that the implementations live next to their tests.

src/utils/async-pipe.test.js
import { asyncPipe } from './async-pipe';
 
const asyncInc = async x => x + 1;
const asyncDouble = async x => x * 2;
 
describe('asyncPipe()', () => {
  test('given two promises: composes them in reverse mathematical order', async () => {
    const asyncIncDouble = asyncPipe(asyncInc, asyncDouble);
 
    const actual = await asyncIncDouble(20);
    const expected = 42;
 
    expect(actual).toEqual(expected);
  });
});

Run your tests with npm test and see that it fails, as expected. It's important to see tests fail before making them pass to ensure they're working correctly.

npm test starts a so-called watch script. This is a long-running process that watches for file changes and automatically reruns your tests when you save your file for all files that have been affected by the change. This watch script runs by default when you use Jest. You need to run this command in your root folder but it will find all files in your project even if they're deeply nested within folders.

In the recordings that you see on screen, you see me running a specific test by using something like npm test async-pipe.test.js. But that is just for your convenience so in this video it's easier to see the respective test's results. You should keep running your watch script, which runs your tests for you whenever you save your file.


Note to Cheta or Ibrahim: Always record watching the test fail, and then after you made the test pass, also record the test passing.

You can run a specific test like this: npm test onboarding-helpers.test.js


 FAIL  src/utils/async-pipe.test.js
  asyncPipe()
 given two promises: composes them in reverse mathematical order (4 ms)
 
 asyncPipe() given two promises: composes them in reverse mathematical order
 
    expect(received).toEqual(expected) // deep equality
 
    Expected: 42
    Received: 0
 
      11 |     const expected = 42;
      12 |
    > 13 |     expect(actual).toEqual(expected);
         |                    ^
      14 |   });
      15 | });
      16 |
 
      at Object.toEqual (src/utils/async-pipe.test.js:13:20)
      at asyncGeneratorStep (node_modules/@babel/runtime/helpers/asyncToGenerator.js:3:17)
      at _next (node_modules/@babel/runtime/helpers/asyncToGenerator.js:17:9)
 
Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.858 s, estimated 1 s
Ran all test suites.
 
Watch Usage: Press w to show more.

Now implement the asyncPipe function to make it pass.

src/utils/async-pipe.js
export const asyncPipe =
  (...fns) =>
  x =>
    fns.reduce(async (y, f) => f(await y), x);

If you're unfamiliar with asyncPipe, it's a function that composes asynchronous functions from left to right, passing the result of each function to the next. To learn more, watch "JavaScript Promises Explained, But On A Senior-Level", which breaks down the asyncPipe function step-by-step.

Onboarding Screen

You can write the first feature without using Redux. This demonstrates an important lesson: always consider whether you truly need Redux for a feature. Sometimes, the answer is "no".

The onboarding logic will manage the notification permissions.

First, install the necessary Expo packages:

$ npx expo install expo-device
$ npx expo install expo-notifications

expo-device is a library that allows you to access system information about the physical device your app is running on. This includes data like the device's manufacturer and model.

expo-notifications is a library that handles all aspects of local notifications for your app. It helps you manage how notifications are presented, scheduled, and responded to.

Create a constants file for onboarding in src/features/onboarding/onboarding-constants.js:

src/features/onboarding/onboarding-constants.js
export const onboardingStorageKeys = {
  /**
   * The user was asked for notification permissions during onboarding.
   */
  WAS_ASKED_FOR_PERMISSIONS: 'wasAskedForPermissions',
  /**
   * The user completed the onboarding and pressed the "Get Started" button.
   */
  GOT_STARTED: 'gotStarted',
};

You'll use these keys later to save the user's onboarding progress to AsyncStorage, which is React Native's local storage solution.

Next, create onboarding helpers to determine whether the user has granted notification permissions. Start with an empty isGranted function.

src/features/onboarding/onboarding-helpers.js
export const isGranted = () => {};

Write the tests for the isGranted function.

src/features/onboarding/onboarding-helpers.js
import { isGranted } from './onboarding-helpers';
 
describe('isGranted()', () => {
  test.each([
    { status: 'granted', expected: true },
    { status: 'denied', expected: false },
    { status: 'undetermined', expected: false },
  ])('given a status of $status: returns $expected', ({ status, expected }) => {
    const actual = isGranted(status);
 
    expect(actual).toEqual(expected);
  });
});

Use Jest's .each modifier, which allows you to write a test prose once, and then run the same test with different setup data.

You make sure that for the different status values, the function returns the expected result.

Watch the tests fail, then implement isGranted to make the test pass.


Note to Cheta / Ibrahim: Always reveal the whole JSDoc string first, so:

1.)

export const isGranted = () => {};

2.) reveal ALL JSDoc in one go

/**
 * Checks if the given permission status is 'granted'.
 *
 * @param {string} status - The permission status to check.
 * @returns {boolean} True if the status is 'granted', false otherwise.
 *
 * @example
 * isGranted('granted') // returns true
 * isGranted('denied') // returns false
 * isGranted('undetermined') // returns false
 */
export const isGranted = () => {};

3.)

src/features/onboarding/onboarding-helpers.js
import { equals } from 'ramda';
 
/**
 * Checks if the given permission status is 'granted'.
 *
 * @param {string} status - The permission status to check.
 * @returns {boolean} True if the status is 'granted', false otherwise.
 *
 * @example
 * isGranted('granted') // returns true
 * isGranted('denied') // returns false
 * isGranted('undetermined') // returns false
 */
export const isGranted = equals('granted');

src/features/onboarding/onboarding-helpers.js
import { equals } from 'ramda';
 
/**
 * Checks if the given permission status is 'granted'.
 *
 * @param {string} status - The permission status to check.
 * @returns {boolean} True if the status is 'granted', false otherwise.
 *
 * @example
 * isGranted('granted') // returns true
 * isGranted('denied') // returns false
 * isGranted('undetermined') // returns false
 */
export const isGranted = equals('granted');

Watch your tests pass.

Now, you can write a helper function to check if the user has granted local notification permissions. Do NOT write unit tests for this function because it has many side effects. Testing it would require mocking several modules, and excessive mocking can be a code smell. Explaining when to mock or not is a topic for another video, so I'll leave it with that here.


Note to Cheta or Ibrahim: Now that / when the file(s) get(s) longer, first show the new imports, then slowly scroll down, then implement the function (1. name and empty callback, 2.) TSDOC, 3.) implementation). Obviously still break down the implementation step by step.


import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { equals } from 'ramda';
import { Platform } from 'react-native';
 
/**
 * Checks if the given permission status is 'granted'.
 *
 * @param {string} status - The permission status to check.
 * @returns {boolean} True if the status is 'granted', false otherwise.
 *
 * @example
 * isGranted('granted') // returns true
 * isGranted('denied') // returns false
 * isGranted('undetermined') // returns false
 */
export const isGranted = equals('granted');
 
/**
 * Checks if the app has permission to send local notifications.
 *
 * @async
 * @returns {Promise<boolean>} A promise that resolves to `true` if the app has
 * permission to send local notifications. Otherwise it resolves with `false`.
 *
 * @example
 * const hasPermission = await getHasLocalNotificationsPermissions();
 * if (hasPermission) {
 *   console.log('App has permission to send notifications');
 * } else {
 *   console.log('App does not have permission to send notifications');
 * }
 */
export async function getHasLocalNotificationsPermissions() {
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF231F7C',
    });
  }
 
  if (Device.isDevice) {
    const { status: existingStatus } =
      await Notifications.getPermissionsAsync();
 
    return isGranted(existingStatus);
  }
 
  return false;
}

The getHasLocalNotificationsPermissions function performs the following steps:

  1. For Android devices, it sets up a default notification channel.
  2. Checks if the app is running on a physical device.
  3. If on a physical device, it requests the current notification permissions.
  4. Returns whether the permission status is 'granted'.
  5. If it's not on a physical device, there is no way to send notifications, so it defaults to false.

You also need a function to ask the user to grant notification permissions. It also has side effects, which can't easily be tested without mocking, so you won't write any unit tests for it either.

import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { equals, prop } from 'ramda';
import { Platform } from 'react-native';
 
import { asyncPipe } from '../../utils/async-pipe';
 
/**
 * Checks if the given permission status is 'granted'.
 *
 * @param {string} status - The permission status to check.
 * @returns {boolean} True if the status is 'granted', false otherwise.
 *
 * @example
 * isGranted('granted') // returns true
 * isGranted('denied') // returns false
 * isGranted('undetermined') // returns false
 */
export const isGranted = equals('granted');
 
/**
 * Checks if the app has permission to send local notifications.
 *
 * @returns {Promise<boolean>} A promise that resolves to `true` if the app has
 * permission to send local notifications. Otherwise it resolves with `false`.
 *
 * @example
 * const hasPermission = await getHasLocalNotificationsPermissions();
 * if (hasPermission) {
 *   console.log('App has permission to send notifications');
 * } else {
 *   console.log('App does not have permission to send notifications');
 * }
 */
export async function getHasLocalNotificationsPermissions() {
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF231F7C',
    });
  }
 
  if (Device.isDevice) {
    const { status: existingStatus } =
      await Notifications.getPermissionsAsync();
 
    return isGranted(existingStatus);
  }
 
  return false;
}
 
/**
 * Requests local notifications permissions and checks if they are granted.
 *
 * @returns {Promise<boolean>} A promise that resolves to `true` if permissions
 * are granted, `false` otherwise.
 *
 * @example
 * const permissionsGranted = await requestLocalNotificationsPermissions();
 * if (permissionsGranted) {
 *   console.log('Local notifications permissions granted');
 * } else {
 *   console.log('Local notifications permissions not granted');
 * }
 */
export const requestLocalNotificationsPermissions = asyncPipe(
  Notifications.requestPermissionsAsync,
  prop('status'),
  isGranted,
);

This function uses asyncPipe to chain the following operations:

  1. Request permissions using Notifications.requestPermissionsAsync(). This function triggers a modal on the device asking the user to allow notifications. After user granted or prohibited permissions, it returns a promise that resolves to an object containing data about the notification permissions on the device. You probably have experienced promises where you are waiting on computers to return a response or a result from database. But this promise "waits" for user interaction and resolves with the result of that user's decision.
  2. Extract the 'status' property from the result.
  3. Check if the status is 'granted' using the isGranted function.

You need to check for permissions whenever the app "comes to the foreground". "Comes to the foreground" refers to when an app becomes the active application on a device, visible and interactable by the user. This occurs after being in the background (running but not visible) or inactive (partially active, like during transitioning states or when a call or notification partially obscures the app). The transition to "foreground" means the app is now the primary focus on the device screen.

You're going to isolate this check in a custom hook.

src/features/onboarding/use-app-comes-to-foreground-effect.js
import { useEffect, useRef, useState } from 'react';
import { AppState } from 'react-native';
 
/**
 * A custom React hook that triggers a side effect function whenever the app
 * comes to the foreground.
 *
 * @param {Function} effect - The side effect function to run when the app comes
 * to the foreground.
 * @returns {string} - The current state of the app ('active', 'inactive', or
 * 'background').
 */
export function useAppComesToForegroundEffect(effect) {
  const appState = useRef(AppState.currentState);
  const [appStateVisible, setAppStateVisible] = useState(appState.current);
 
  useEffect(() => {
    // This variable is called subscription because it proactively listens for
    // changes in the app's state until ...
    const subscription = AppState.addEventListener('change', nextAppState => {
      if (
        /inactive|background/.test(appState.current) &&
        nextAppState === 'active'
      ) {
        effect();
      }
 
      appState.current = nextAppState;
      setAppStateVisible(appState.current);
    });
 
    return () => {
      // ... it's unsubscribed in the cleanup function of this useEffect hook.
      subscription.remove();
    };
 
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
 
  return appStateVisible;
}

This hook performs the following steps:

  1. Initializes a ref with the current app state and a state variable to track it.
  2. Sets up an effect that listens for app state changes.
  3. When the app state changes to 'active' from 'inactive' or 'background', it runs the provided effect function. Note: The AppState can be 'active', 'background', 'inactive', 'unknown', or 'extension'.
  4. Updates the ref and state variable with the new app state.
  5. Cleans up the event listener when the component unmounts.

To make it easy to guard a specific screen when the user lacks permissions, you'll create a withNotificationPermissions higher-order component (HOC) that you can wrap around any screen.

src/features/onboarding/with-notification-permissions.js
/* eslint-disable unicorn/consistent-function-scoping */
import { useNavigation } from 'expo-router';
import { useState } from 'react';
 
import { getHasLocalNotificationsPermissions } from './onboarding-helpers';
import { useAppComesToForegroundEffect } from './use-app-comes-to-foreground-effect';
 
export const withNotificationPermissions = Component =>
  function WithNotificationPermissions(properties) {
    const [hasNotificationPermissions, setHasNotificationPermissions] =
      useState(false);
    const navigation = useNavigation();
 
    async function ensureNotificationPermissions() {
      const hasPermissions = await getHasLocalNotificationsPermissions();
      const { routes } = navigation.getState();
 
      if (!hasPermissions && routes[0].name !== 'index') {
        navigation.reset({ index: 0, routes: [{ name: 'index' }] });
      }
 
      setHasNotificationPermissions(hasPermissions);
    }
 
    useAppComesToForegroundEffect(ensureNotificationPermissions);
 
    return (
      <Component
        hasNotificationPermissions={hasNotificationPermissions}
        {...properties}
      />
    );
  };

Import the useNavigation and useState hooks, along with the getHasLocalNotificationsPermissions helper and the useAppComesToForegroundEffect hook.

Define a withNotificationPermissions HOC. It uses useState to track whether the user has granted permissions.

Then, define an ensureNotificationPermissions function that checks if the user has granted permissions using getHasLocalNotificationsPermissions. If the user has prohibited permissions, it redirects them to the onboarding screen using the reset method.

By using the useAppComesToForegroundEffect hook and the ensureNotificationPermissions function, it checks for permissions whenever the app comes to the foreground. This is important because the user could revoke permissions at any time when they leave the app, which would break the app's functionality.

Lastly, it passes whether the user has granted permissions to the wrapped component as a prop. You will later wrap the HOC around the onboarding screen, which uses the hasNotificationPermissions prop to automatically forward users into the main app if they have granted permissions.

Now, create a useEffectOnce hook that runs a function exactly once.

src/features/onboarding/use-effect-once.js
import { useEffect } from 'react';
 
export const useEffectOnce = (effect, deps) => {};

Write the tests for this hook.

src/features/onboarding/use-effect-once.test.js
import { renderHook } from '@testing-library/react-native';
 
import { useEffectOnce } from './use-effect-once';
 
describe('useEffectOnce()', () => {
  test('runs the effect exactly once per component mount', () => {
    const effect = jest.fn();
 
    expect(effect).not.toHaveBeenCalled();
 
    const { rerender } = renderHook(() => useEffectOnce(effect));
 
    expect(effect).toHaveBeenCalledTimes(1);
 
    rerender();
 
    expect(effect).toHaveBeenCalledTimes(1);
  });
});

You can test hooks by using the renderHook function from Testing Library. In this you will render the hook twice. You need to create a mock function using jest.fn(). You can run assertions on this mock function. Check that it hasn't been called. Then render your hook, which happens implicitly when you call renderHook, and check that it has been called. Rerender the hook and assert that it hasn't been called again.

After watching your tests fail, implement the useEffectOnce hook.

src/features/onboarding/use-effect-once.js
import { useEffect, useRef } from 'react';
 
/**
 * Accepts a function that contains imperative, possibly effectful code.
 * It executes that function exactly once.
 *
 * Can be used to avoid shooting us in the foot with React 18 and strict mode.
 * @see https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state
 * @see https://github.com/reactwg/react-18/discussions/18
 *
 * @param effect Imperative function that can return a cleanup function
 */
export function useEffectOnce(effect) {
  const isFirstMount = useRef(true);
 
  useEffect(() => {
    if (isFirstMount) {
      isFirstMount.current = false;
 
      return effect();
    }
 
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
}

Use useRef to check that the effect only runs if the component is mounted for the first time. It's not enough to have an empty dependency array because React 18 mounts and unmounts your component twice during development mode.

Let's tie all of these functions together in your React components. You're going to use the display / container component pattern. Display components are responsible for how things look - they are pure functions that take in props and return JSX. Container components handle logic and side effects. They contain hooks and are connected to your Redux store.

In true TDD fashion, create a component that renders nothing.

src/features/onboarding/onboarding-screen-component.js
export function OnboardingScreenComponent() {
  return null;
}

When you're testing a display component, you first need to think about what it should render. This determines the props the component receives, which depend on what the component should display.

By default, the onboarding screen should show a message informing the user that they will be asked for permission to send notifications. It should also have a button that starts the permission request flow. Therefore, it needs an onRequestPermissionPress function prop.

If the user was asked for permissions but declined, the screen needs to show a message telling them to go to their phone's settings to allow push notifications manually. This means the component needs wasAskedForPermissions and hasAllowedPermissions boolean props.

If the user was asked for permissions and allowed them, the screen should show a message indicating they can now use the app. It should also have a button to send a test notification and a button to navigate to the home screen. Therefore, it needs onSendTestNotificationPress and onGetStartedPress function props.

Capture these requirements in your tests.

src/features/onboarding/onboarding-screen-component.test.js
import { render, screen } from '@testing-library/react-native';
 
import { OnboardingScreenComponent } from './onboarding-screen-component';
 
const createProps = ({
  hasAllowedPermissions = false,
  onGetStartedPress = jest.fn(),
  onRequestPermissionPress = jest.fn(),
  onSendTestNotificationPress = jest.fn(),
  wasAskedForPermissions = false,
} = {}) => ({
  hasAllowedPermissions,
  onGetStartedPress,
  onRequestPermissionPress,
  onSendTestNotificationPress,
  wasAskedForPermissions,
});
 
describe('OnboardingScreen component', () => {
  test('given the user was not asked for permission: shows a message that they will be asked for permission and a button to trigger the permission asking flow', () => {
    const props = createProps();
 
    render(<OnboardingScreenComponent {...props} />);
 
    expect(screen.getByText(/allow push notifications/i)).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /request permission now/i }),
    ).toBeOnTheScreen();
  });
 
  test('given the user was asked for permission, but declined: shows a message that they need to go into their settings to allow push notifications manually', () => {
    const props = createProps({
      wasAskedForPermissions: true,
      hasAllowedPermissions: false,
    });
 
    render(<OnboardingScreenComponent {...props} />);
 
    expect(screen.getByText(/go to your settings/i)).toBeOnTheScreen();
  });
 
  test('given the user was asked for permission and allowed: shows a message that they can now use the app, a button to send a test notification, and a button to navigate to the home screen', () => {
    const props = createProps({
      wasAskedForPermissions: true,
      hasAllowedPermissions: true,
    });
 
    render(<OnboardingScreenComponent {...props} />);
 
    expect(screen.getByText(/you can now use the app/i)).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /send test notification/i }),
    ).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /get started/i }),
    ).toBeOnTheScreen();
  });
});

First, create a createProps factory function to make it easy to generate the props for the component, so you only need to override the ones you need for each specific test case.

Then, write the first test that checks if the user was not asked for permissions. It should display a message informing the user that they will be asked for permission and a button to request permissions.

Your second test should check that if the user was asked for permissions but declined, it displays a message telling them to go to their settings to allow push notifications manually. In this test, you explicitly set hasAllowedPermissions to false, even though hasAllowedPermissions is false by default. This is good practice to make sure that the test always behaves the same even if the createProps factory function changes.

Your third and final test should verify that if the user was asked for permissions and allowed them, it displays a message indicating they can now use the app, along with a button to send a test notification and a button to navigate to the home screen.

Notice how easy it is to read the test setup using the createProps factory function because it only mentions what's special about each specific test case.

Before you jump into the implementation, install the expo-image package.

$ npx expo install expo-image

You'll use it to render your app's logo.

After seeing your tests fail, implement the component.


Note to Cheta or Ibrahim: For all UI code examples, after the imports, record the creation of the styles object first. This will make it easier to use them in the JSX. (See the narration below this code example for context.)

For this code example, because there's a lot of narration about the styles, "code them up" bit by bit. For example:

1.)

const styles = StyleSheet.create({});

2.)

const styles = StyleSheet.create({
  container: {
    backgroundColor: darkColors.backgroundColorPrimary,
    flex: 1,
  },
});

3.)

const styles = StyleSheet.create({
  container: {
    backgroundColor: darkColors.backgroundColorPrimary,
    flex: 1,
  },
  safeArea: {
    alignItems: 'center',
    flex: 1,
    paddingHorizontal: 16,
  },
});

... and so on.

Because the narration for this code block explaining StyleSheet will be longer.

For the code examples following this one, I'll only quickly add a line like: "Create the styles for this component." So there you can record the whole styles object in one go without revealing it bit by bit. (Only exception: if the styles are too many and the object is "higher" than the screen height, then record it in large chunks [e.g. halfs, or thirds].)


src/features/onboarding/onboarding-screen-component.js
import { Button, Text } from '@rneui/themed';
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
 
import { darkColors } from '../../styles/colors';
 
const noop = () => {};
 
export function OnboardingScreenComponent({
  hasAllowedPermissions = false,
  onGetStartedPress = noop,
  onRequestPermissionPress = noop,
  onSendTestNotificationPress = noop,
  wasAskedForPermissions = false,
}) {
  return (
    <SafeAreaProvider>
      <View style={styles.container}>
        <SafeAreaView style={styles.safeArea}>
          <Image
            alt="Random Reminders"
            contentFit="cover"
            // eslint-disable-next-line unicorn/prefer-module
            source={require('../../../assets/images/icon.png')}
            style={styles.logo}
          />
 
          {wasAskedForPermissions ? (
            hasAllowedPermissions ? (
              <>
                <Text style={{ alignSelf: 'center' }} h4>
                  You can now use the app.
                </Text>
 
                <View style={styles.buttonContainer}>
                  <Button
                    buttonStyle={styles.secondaryButton}
                    onPress={onSendTestNotificationPress}
                    size="lg"
                  >
                    Send Test Notification
                  </Button>
 
                  <Button onPress={onGetStartedPress} size="lg">
                    Get Started
                  </Button>
                </View>
              </>
            ) : (
              <View style={styles.instructions}>
                <Text h4 style={styles.text}>
                  Push notification access has been denied.
                </Text>
 
                <Text h4 style={styles.text}>
                  Since we can't ask you again, please go to your settings to
                  allow push notifications.
                </Text>
              </View>
            )
          ) : (
            <>
              <View style={styles.instructions}>
                <Text h4 style={styles.text}>
                  You must allow push notifications to use this app, so you can
                  get notifications for your reminders.
                </Text>
 
                <Text h4 style={styles.text}>
                  Click the button below and then allow notifications in the
                  popup.
                </Text>
              </View>
 
              <Button onPress={onRequestPermissionPress} size="lg">
                Request permission now
              </Button>
            </>
          )}
        </SafeAreaView>
      </View>
    </SafeAreaProvider>
  );
}
 
const styles = StyleSheet.create({
  container: {
    backgroundColor: darkColors.backgroundColorPrimary,
    flex: 1,
  },
  safeArea: {
    alignItems: 'center',
    flex: 1,
    paddingHorizontal: 16,
  },
  logo: {
    height: 256,
    width: 256,
  },
  text: {
    lineHeight: 32,
    textAlign: 'center',
  },
  instructions: {
    gap: 24,
    marginBottom: 48,
    width: '100%',
  },
  secondaryButton: {
    backgroundColor: darkColors.secondary,
  },
  buttonContainer: {
    gap: 24,
    marginTop: 'auto',
    width: '100%',
  },
});

Import the necessary components from React Native Elements, Expo, and React Native, as well as your colors.

Create a styles object using StyleSheet.create at the bottom of your file. If you've never used React Native, StyleSheet is a tool that allows you to define styles for your application's interface. You create an object where each key is a style name, and the associated value is a set of CSS-like properties. These styles are then applied to components using the style prop to customize their appearance. I'm going to show you the styles on screen for all UI components, but I won't explain them in-depth going forward. If you're coding along, you can also grab the styles from the GitHub repository linked in the description.

Create a noop function to use as a default for function props. This makes it safe to use your display component anywhere and if a developer forgets to pass in a function prop, the app won't crash when the user presses a button.

Then, define your component. It takes in the props we discussed earlier and renders a SafeAreaProvider with a View. Inside that View is the SafeAreaView, which guards against the notches on iOS devices.

Render the Image with your app's logo at the top of the component.

Then, render the UI for the different states of the component:

  • If the user was asked for permissions and has allowed them, render the respective message and the buttons.
  • If they declined, render the respective message.
  • If they were not asked for permissions yet, render the respective message and the button to request permissions.

Run your tests again and watch them pass.

You can now tie everything together in your container component.

src/features/onboarding/onboarding-screen-container.js
/* eslint-disable unicorn/consistent-function-scoping */
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
import { useState } from 'react';
 
import { onboardingStorageKeys } from './onboarding-constants';
import {
  getHasLocalNotificationsPermissions,
  requestLocalNotificationsPermissions,
} from './onboarding-helpers';
import { OnboardingScreenComponent } from './onboarding-screen-component';
import { useEffectOnce } from './use-effect-once';
import { withNotificationPermissions } from './with-notification-permission';
 
function OnboardingScreenContainer({ hasNotificationPermissions }) {
  const [hasAllowedPermissions, setHasAllowedPermissions] = useState(
    hasNotificationPermissions,
  );
  const [wasAskedForPermissions, setWasAskedForPermissions] = useState(false);
 
  useEffectOnce(() => {
    async function init() {
      const currentPermissions = await getHasLocalNotificationsPermissions();
      setHasAllowedPermissions(currentPermissions);
 
      const [wasAsked, gotStarted] = await Promise.all([
        AsyncStorage.getItem(onboardingStorageKeys.WAS_ASKED_FOR_PERMISSIONS),
        AsyncStorage.getItem(onboardingStorageKeys.GOT_STARTED),
      ]);
 
      if (wasAsked !== null) {
        setWasAskedForPermissions(true);
      }
 
      if (
        (currentPermissions || hasNotificationPermissions) &&
        gotStarted !== null
      ) {
        router.replace('/home');
      }
    }
 
    init();
  });
 
  async function handleRequestPermissionPress() {
    await requestLocalNotificationsPermissions().then(setHasAllowedPermissions);
 
    setWasAskedForPermissions(true);
 
    await AsyncStorage.setItem(
      onboardingStorageKeys.WAS_ASKED_FOR_PERMISSIONS,
      JSON.stringify(true),
    );
  }
 
  async function handleSendTestNotificationPress() {
    await Notifications.scheduleNotificationAsync({
      content: {
        body: 'This is a test notification.',
        title: 'Random Reminder App',
      },
      trigger: null,
    });
  }
 
  async function handleGetStartedPress() {
    await AsyncStorage.setItem(
      onboardingStorageKeys.GOT_STARTED,
      JSON.stringify(true),
    );
 
    router.replace('/home');
  }
 
  return (
    <OnboardingScreenComponent
      hasAllowedPermissions={
        // This order is important because hasAllowedPermissions only updates
        // when the user is on this screen whereas hasNotificationPermissions
        // updates when the app switches between foreground and background.
        hasAllowedPermissions || hasNotificationPermissions
      }
      onGetStartedPress={handleGetStartedPress}
      onRequestPermissionPress={handleRequestPermissionPress}
      onSendTestNotificationPress={handleSendTestNotificationPress}
      wasAskedForPermissions={wasAskedForPermissions}
    />
  );
}
 
export default withNotificationPermissions(OnboardingScreenContainer);

First, import necessary modules and components. You'll need AsyncStorage for storing some flags across app restarts, Notifications for handling notifications, router from expo-router for navigation, and useState for React state management.

Define the OnboardingScreenContainer function that takes hasNotificationPermissions as a prop. This prop will come from the withNotificationPermissions HOC, which will wrap this container.

Inside, initialize two states with useState: hasAllowedPermissions and wasAskedForPermissions. You will use these states to keep track of whether the user has allowed notifications and whether they have been prompted yet.

Inside the useEffectOnce hook, define an asynchronous function init. This function performs several operations:

  1. It checks the current permission status with getHasLocalNotificationsPermissions.
  2. It retrieves values from AsyncStorage to check if the user was previously asked for permissions. This state is persisted even if the user closed the app and re-opens it again.
  3. If the user granted permissions and already got started, navigate the user directly to the home screen using router.replace.

Because the onboarding screen will always render first when the app is loaded, the init function ensures that users who have already granted permissions and completed onboarding are taken directly to the home screen.

Next, define the handleRequestPermissionPress function. This function will be triggered when the user presses the button to request permissions. It calls requestLocalNotificationsPermissions, updates the permissions state, and sets a flag in AsyncStorage indicating that the user has been asked for permissions.

For the handleSendTestNotificationPress, this function schedules a test notification using the Notifications API. This is a simple way to show that notifications are working as expected.

Lastly, the handleGetStartedPress function is triggered when the user presses the "Get Started" button after granting permissions. It sets a flag in AsyncStorage and navigates the user to the home screen.

Now, render the OnboardingScreenComponent and pass all the necessary props to it. And finally, wrap the container in the withNotificationPermissions HOC and export it.


Note to Cheta or Ibrahim: Record the renaming of the root file below, too.

If there are changes to your tsconfig.json, make sure to delete them. It sometimes updates automatically, but we don't care.

After this next step, record the app with the "You must allow" message, but don't press the "Request permission now" button yet.


Your root route might still be named index.tsx, so rename it to index.js.

src/app/index.js
export { default } from '../features/onboarding/onboarding-screen-container';

Export the OnboardingScreenContainer from it.

When you now reload your app, you can see the onboarding screen. Don't press the "Request permission now" button yet though because your app lacks the home screen, so it will crash.

Set Up Redux

All subsequent features will use Redux, so it's time to set it up.

Start by creating your root reducer in src/redux/root-reducer.js.

src/redux/root-reducer.js
import { combineReducers } from '@reduxjs/toolkit';
 
export const rootReducer = combineReducers({
  ['temp-not-used-to-avoid-crash']: (state = {}) => state,
});
 
export const rootState = rootReducer(undefined, { type: '' });

Import combineReducers from Redux Toolkit and create a root reducer that does nothing for now. This temporary reducer prevents your app from crashing until you add actual reducers. Also, export rootState, which you'll use in your unit tests later.

Here, you create the root state by calling the root reducer with undefined and an empty action as arguments. This guards your app against refactors with Redux Toolkit. While the reducers that you're going to write in this tutorial can be called without arguments and still return their initial state, the reducers created by createSlice lack this behavior.

Next, create an empty root saga in src/redux/root-saga.js. You will use sagas in the last third of this tutorial to schedule your reminders. While we're setting up the store, it's convenient to also configure the saga middleware already.

import { all } from 'redux-saga/effects';
 
export function* rootSaga() {
  yield all([]);
}

In your root saga, import the all effect from redux-saga/effects and define a generator function rootSaga that currently yields an empty array.

Now, set up your store in src/redux/store.js.

src/redux/store.js
import AsyncStorage from '@react-native-async-storage/async-storage';
import { configureStore } from '@reduxjs/toolkit';
import { persistReducer, persistStore } from 'redux-persist';
import createSagaMiddleware from 'redux-saga';
 
import { rootReducer } from './root-reducer';
import { rootSaga } from './root-saga';
 
const persistConfig = {
  key: 'root',
  storage: AsyncStorage,
};
 
const persistedReducer = persistReducer(persistConfig, rootReducer);
 
const sagaMiddleware = createSagaMiddleware();
 
export const store = configureStore({
  reducer: persistedReducer,
  middleware: getDefaultMiddleware => [
    ...getDefaultMiddleware({ serializableCheck: false, thunk: false }),
    sagaMiddleware,
  ],
});
 
sagaMiddleware.run(rootSaga);
 
export const persistor = persistStore(store);

Import AsyncStorage, configureStore, middleware and your root reducer and saga.

Create a persistConfig object, which is configured to save your Redux state to AsyncStorage. This will save your Redux state across app restarts.

Use persistReducer and the persistConfig to create a persisted version of your root reducer.

Afterwards, initialize the saga middleware.

Create your store using the persisted reducer and middleware. If your only contact with Redux has been through this five-part series, you may have noticed that you create the store as a variable, instead of using a makeStore function. As mentioned in the previous videos, the makeStore function is really only needed for Next.js.

Run your root saga with sagaMiddleware.run(rootSaga), and lastly, export the store and persistor.

If at any point during development your app is in a buggy state or behaves differently from this tutorial, it could be because a weird state has been persisted. You can purge the state by calling persistor.purge() in your store.js file. But remember to only do this in development.

Next, integrate your store and persistor into your root layout.

src/app/_layout.js
import { ThemeProvider } from '@rneui/themed';
import { Slot } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
 
import { persistor, store } from '../redux/store';
import { darkTheme } from '../styles/themes';
 
export default function Layout() {
  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <ThemeProvider theme={darkTheme}>
          <StatusBar style="light" />
          <Slot />
        </ThemeProvider>
      </PersistGate>
    </Provider>
  );
}

Import Provider from react-redux and PersistGate from redux-persist/integration/react. Then, wrap your app with the Provider component, passing in the store as a prop. Inside the Provider, wrap your content with PersistGate, providing the persistor to ensure that your app waits for the persisted state to be retrieved before rendering.

Home Screen

On the home screen, you want to show your user a list of reminders. The app should come with a few hardcoded reminders to uplift the user. Here is the list of default reminders for this tutorial:

  • You got this! 💪
  • Learn Redux at a senior-level.
  • Give a stranger a compliment 🫂
  • Subscribe to Jan Hesters on YouTube!

The home screen and all upcoming screens are going to be rendered in a stack navigator. A stack navigator in Expo manages navigation between screens, where each new screen is placed on top of a stack, much like pages in a book. Users navigate forward by pushing a screen onto the stack and go back by popping it off.


Note to Cheta or Ibrahim: Show a recording of this stack transition of "pushing" and "popping" screens so we can overlay it here.

Begin by creating a new folder src/app/(main) to group the main screens of your app.

Next, add a layout for the stack navigator in src/app/(main)/_layout.js:

src/app/(main)/_layout.js
import { Stack } from 'expo-router/stack';
 
import { darkColors } from '../../styles/colors';
 
export default function Layout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: {
          backgroundColor: darkColors.backgroundColorPrimary,
        },
        headerTintColor: darkColors.textColorPrimary,
        headerShadowVisible: false,
      }}
    />
  );
}

Import Stack from expo-router/stack. Configure the stack navigator's screenOptions to style the header with your chosen background color, text color, and to hide the header shadow.

Next, create a general src/components/ folder for components that are not specific to a single feature and add a header button component.

src/components/header-button.js
import { Button, Icon } from '@rneui/themed';
 
export function HeaderButton({ iconName, ...props }) {
  return (
    <Button buttonStyle={{ backgroundColor: 'transparent' }} {...props}>
      <Icon color="white" name={iconName} type="ionicon" />
    </Button>
  );
}

Import Button and Icon from @rneui/themed. Create a HeaderButton that renders a button with a transparent background and an icon. The iconName prop specifies which icon to display.

You can configure layouts directly in the layout file or by using higher-order components (HOCs). So, create a higher-order component (HOC) to handle header configurations for your stack navigator.

src/features/navigation/with-header.js
import { Stack } from 'expo-router';
 
export const withHeader = options => Component =>
  function WithHeader(props) {
    return (
      <>
        <Stack.Screen
          options={{
            headerTintColor: '#fff',
            ...options,
          }}
        />
        <Component {...props} />
      </>
    );
  };

Curry your withHeader component to first take in options for the header configuration. The Stack.Screen component is used to configure the appearance of the screen's header. In this case, you make the header text white.

You're going to use the withHeader HOC as a wrapper for your home screen, so you'll need to create the related reducers first.

Since you'll be saving your reminders in Redux, it's time to apply TDD to create your first reducer.


Note to Cheta or Ibrahim: In these TDD blocks, follow the narration after each code example, so that its easy for the editors to keep the pace.

In this case, first reveal the initial state and then reducer, both as a hole, instead of bit by bit.

E.g. good ✅

1.)

export const sliceName = 'reminders';

2.)

export const sliceName = 'reminders';
 
const initialState = {};

3.)

export const sliceName = 'reminders';
 
const initialState = {};
 
export const reducer = (state = initialState, { type, payload } = {}) => state;

E.g. bad ❌

1.)

export const sliceName = 'reminders';

2.)

export const sliceName = 'reminders';
 
const initialState = {};

3.)

export const sliceName = 'reminders';
 
const initialState = {};
 
export const reducer = (state = initialState) => state;

4.)

const initialState = {};
 
export const reducer = (state = initialState, { type, payload } = {}) => state;

Basically, follow the narration as much as possible, so it's neither too granular, nor too chunky.


src/features/reminders/reminders-reducer.js
export const sliceName = 'reminders';
 
const initialState = {};
 
export const reducer = (state = initialState, { type, payload } = {}) => state;
 
export const selectRemindersSlice = () => {};
 
export const selectRemindersArray = () => {};

Export a sliceName constant that is the name of the slice.

Define an empty initial state and a reducer that returns its state unchanged. Additionally, define two selectors that should return the reminders array from the state, but they do nothing yet.

Writing tests for reducers is easy because every component in Redux - actions, reducers, and selectors - is a pure function.

src/features/reminders/reminders-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import { selectRemindersArray } from './reminders-reducer';
 
describe('reminders reducer', () => {
  describe('selectRemindersArray() selector', () => {
    test('given the initial state: returns the default reminders array', () => {
      const state = rootReducer();
 
      const actual = selectRemindersArray(state);
      const expected = [
        {
          id: 'hardcoded-first-reminder',
          message: 'You got this! 💪',
          dateCreated: '2024-10-01T00:00:00.000Z',
        },
        {
          id: 'hardcoded-second-reminder',
          message: 'Learn Redux at a senior-level.',
          dateCreated: '2024-10-02T00:00:00.000Z',
        },
        {
          id: 'hardcoded-third-reminder',
          message: 'Give a stranger a compliment 🫂',
          dateCreated: '2024-10-03T00:00:00.000Z',
        },
        {
          id: 'hardcoded-fourth-reminder',
          message: 'Subscribe to Jan Hesters on YouTube!',
          dateCreated: '2024-10-04T00:00:00.000Z',
        },
      ];
 
      expect(actual).toEqual(expected);
    });
  });
});

Import the root reducer and the selectRemindersSlice selector. Then write a test that checks if the selector applied to the initial state of the root reducer returns the default reminders array. Give each reminder in that array a fake ID and date created because those details don't matter for these default reminders.

You write tests for selectors using the root reducer because selectors take in the current root state as an argument.

Run your test and watch it fail.

Before you implement the reducer, you first need to hook it up in your root reducer.

src/redux/root-reducer.js
import { combineReducers } from '@reduxjs/toolkit';
 
import {
  reducer as remindersReducer,
  sliceName as remindersSliceName,
} from '../features/reminders/reminders-reducer';
 
export const rootReducer = combineReducers({
  [remindersSliceName]: remindersReducer,
});
 
export const rootState = rootReducer(undefined, { type: '' });

Import your reminders reducer and slice name, and add the reminders reducer to your combineReducers call.

Now, implement the reminders reducer and the selector.

src/features/reminders/reminders-reducer.js
import { pipe, prop, values } from 'ramda';
 
export const sliceName = 'reminders';
 
const initialState = {
  reminders: {
    'hardcoded-first-reminder': {
      id: 'hardcoded-first-reminder',
      message: 'You got this! 💪',
      dateCreated: '2024-10-01T00:00:00.000Z',
    },
    'hardcoded-second-reminder': {
      id: 'hardcoded-second-reminder',
      message: 'Learn Redux at a senior-level.',
      dateCreated: '2024-10-02T00:00:00.000Z',
    },
    'hardcoded-third-reminder': {
      id: 'hardcoded-third-reminder',
      message: 'Give a stranger a compliment 🫂',
      dateCreated: '2024-10-03T00:00:00.000Z',
    },
    'hardcoded-fourth-reminder': {
      id: 'hardcoded-fourth-reminder',
      message: 'Subscribe to Jan Hesters on YouTube!',
      dateCreated: '2024-10-04T00:00:00.000Z',
    },
  },
};
 
export const reducer = (state = initialState, { type, payload } = {}) => state;
 
const selectRemindersSlice = prop(sliceName);
 
export const selectRemindersArray = pipe(
  selectRemindersSlice,
  prop('reminders'),
  values,
);

Import pipe, prop, and values from Ramda.

Add the default reminders to the initial state. You normalize this state as an object where each key is the reminder's ID and the value is the reminder itself, because it's easier to work with normalized data.

Create a selector that grabs the reminders slice from the root state.

Then compose the selectRemindersArray selector using values, which retrieves each value from an object and returns them in an array. You want to have the reminders as an array so you can map over them in your component in a list.

Run your test again. This time, it passes.

On the home screen, you also want to delete reminders. To do this, add an action to delete a reminder by its ID.

src/features/reminders/reminders-reducer.js
// ... your initial state
 
export const reducer = (state = initialState, { type, payload } = {}) => state;
 
export const reminderDeleted = reminderId => ({
  type: `${sliceName}/reminderDeleted`,
  payload: reminderId,
});
 
// ... your selectors

Add a test for the reminderDeleted action creator.

src/features/reminders/reminders-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import { reminderDeleted, selectRemindersArray } from './reminders-reducer';
 
describe('reminders reducer', () => {
  describe('selectRemindersArray() selector', () => {
    // ... existing test
 
    test('given a reminder deleted action and the id of the reminder to delete: deletes the reminder and returns the correct array', () => {
      const reminderId = 'hardcoded-third-reminder';
      const state = rootReducer(undefined, reminderDeleted(reminderId));
 
      const actual = selectRemindersArray(state);
      const expected = [
        {
          id: 'hardcoded-first-reminder',
          message: 'You got this! 💪',
          dateCreated: '2024-10-01T00:00:00.000Z',
        },
        {
          id: 'hardcoded-second-reminder',
          message: 'Learn Redux at a senior-level.',
          dateCreated: '2024-10-02T00:00:00.000Z',
        },
        {
          id: 'hardcoded-fourth-reminder',
          message: 'Subscribe to Jan Hesters on YouTube!',
          dateCreated: '2024-10-04T00:00:00.000Z',
        },
      ];
 
      expect(actual).toEqual(expected);
    });
  });
});    

Write a test that calculates the state after the reminderDeleted action has been dispatched. Then use the selectRemindersArray selector to check if the array without the deleted reminder is returned.

Before you watch this test fail, notice how the test couples the action creator to the selector. Action creators and selectors are like pairs of getters and setters. They are the only interface you should use to interact with your Redux store and therefore it is best practice to test them together like this.

Using action creators in conjunction with your selectors and your root reducer in your tests like this gives you confidence that:

  1. You hooked up your slice's reducer to your rootReducer, and you did it correctly.
  2. Your action creators modify your state in the way that you intended (= the right slices, the right data shape, etc.).
  3. Your action creators interact correctly with each other (- if you use several to set up the test, which you will see later in this tutorial when you toggle on which days your user wants to receive reminders).
  4. Your selectors return the correct value.

You should test selectors with at least two cases:

  1. One with default state, and
  2. one with modified state.

For the default state, you can call the rootReducer without arguments to get the global initial state. You did this in your first test, which returned the default reminders array. And again, if you can't call your rootReducer without arguments to produce the default state. For example because you're using Redux Toolkit's createSlice. In this case you can create and export the rootState from your root reducer file.

src/redux/root-reducer.js
// ...
 
export const rootState = rootReducer(undefined, { type: '' });

To test the selector's behavior with populated state, you can simply pass the action as the second argument to your root reducer, as you did in the test for the reminderDeleted action.

If your selector depends on several pieces of state managed by multiple actions, you can create an actions array and reduce over it to build up the state. You will see examples of this in a later tutorial.

Now watch your test fail, and then implement the case to handle the reminderDeleted action in your reducer.


Note to Cheta or Ibrahim: In cases like this, modify the relevant code in the reducer file, so do NOT show the comments (// ... your initial state etc.) like I'm doing for the script / article version.


src/features/reminders/reminders-reducer.js
// ... your initial state
 
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case reminderDeleted().type: {
      const { [payload]: _, ...remainingReminders } = state.reminders;
 
      return { ...state, reminders: remainingReminders };
    }
    default: {
      return state;
    }
  }
};
 
// ... your action

Run your tests and watch them pass.

You're going to render your reminders in a list. So, create a ReminderItem component.

src/features/home/reminder-item-component.js
export function ReminderItemComponent() {
  return null;
}

As always, when you TDD a display component, you want to first think about what props it should receive. Your reminder item should render a message and a delete button. When pressing the item in general, it should redirect you to the reminder details screen.

To write this test, you need a reminder fixture. It is good practice to create your fixtures with factory functions. So, create a file that contains your reminder factory functions.

src/features/reminders/reminders-factories.js
import { faker } from '@faker-js/faker';
import { createId } from '@paralleldrive/cuid2';
 
export const createReminder = ({
  id = '',
  message = '',
  dateCreated = new Date().toISOString(),
} = {}) => ({ message, id, dateCreated });
 
export const createPopulatedReminder = ({
  id = createId(),
  message = faker.word.words(),
  dateCreated = faker.date.recent().toISOString(),
} = {}) => createReminder({ id, message, dateCreated });

Whenever you create reminders, whether in your tests or your actual code, you should use the createReminder factory function. It provides default parameters, so you only need to provide the values that are unique to the reminder you're creating. In general, factories make it easy to amend your entities. If you create all entities in your app with factories, they will automatically receive the new properties. As a neat little bonus, you even get type inference from them, even in JavaScript.

The createPopulatedReminder factory function uses faker to generate mock data. The faker package is a JavaScript library that creates large amounts of fake but realistic data for testing and development. It can generate names, addresses, phone numbers, emails, lorem ipsum text, and more. You'll use this factory to create realistic reminder fixtures for your tests. If you've never heard the word "fixture", it means the predefined data used in testing scenarios.

Now write your tests for the ReminderItemComponent.

src/features/home/reminder-item-component.test.js
import { fireEvent, render, screen } from '@testing-library/react-native';
 
import { createPopulatedReminder } from '../reminders/reminders-factories';
import { ReminderItemComponent } from './reminder-item-component';
 
const createProps = ({
  id = createPopulatedReminder().id,
  message = createPopulatedReminder().message,
  onDeletePressed = jest.fn(),
  onPress = jest.fn(),
} = {}) => ({ id, message, onDeletePressed, onPress });
 
describe('ReminderItemComponent', () => {
  test('given a message: displays the message', () => {
    const props = createProps();
 
    render(<ReminderItemComponent {...props} />);
 
    expect(screen.getByText(props.message)).toBeOnTheScreen();
  });
 
  test('given an on press handler: clicking the item triggers the listener', () => {
    const props = createProps();
 
    render(<ReminderItemComponent {...props} />);
 
    const button = screen.getByRole('button', { name: props.message });
    fireEvent.press(button);
 
    expect(props.onDeletePressed).toHaveBeenCalledTimes(0);
    expect(props.onPress).toHaveBeenCalledWith({ id: props.id });
  });
 
  test('given an on delete pressed handler: clicking the delete button triggers the listener', () => {
    const props = createProps();
 
    render(<ReminderItemComponent {...props} />);
 
    const button = screen.getByLabelText(/delete/i);
    fireEvent.press(button);
 
    expect(props.onPress).toHaveBeenCalledTimes(0);
    expect(props.onDeletePressed).toHaveBeenCalledWith({ id: props.id });
  });
});

Define the createProps function and use your factory to populate the defaults.

In your first test, check if the reminder message is displayed correctly using screen.getByText to locate the text element containing the message.

In your second test, ensure that when the reminder item is pressed, the appropriate handler is triggered. Use screen.getByRole to get the button associated with the reminder message and fireEvent.press to trigger the press event. Then check that the onPress handler was called with the id of the reminder.

Finally, test the delete functionality by checking that pressing the delete button calls the onDeletePressed handler with the correct identifier.

Watch your tests fail, and then implement the component.

src/features/home/reminder-item-component.js
import { Button, Text } from '@rneui/themed';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
 
import { darkColors } from '../../styles/colors';
 
export function ReminderItemComponent({
  id,
  message,
  onDeletePressed,
  onPress,
}) {
  return (
    <TouchableOpacity
      accessibilityRole="button"
      activeOpacity={0.8}
      onPress={() => onPress({ id })}
      style={styles.container}
    >
      <View style={styles.textContainer}>
        <Text style={styles.text}>{message}</Text>
      </View>
 
      <Button
        accessibilityLabel="delete"
        icon={{
          color: darkColors.textSecondary,
          name: 'trash-outline',
          size: 20,
          type: 'ionicon',
        }}
        onPress={() => onDeletePressed({ id })}
      />
    </TouchableOpacity>
  );
}
 
const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    backgroundColor: darkColors.backgroundColorSecondary,
    borderBottomColor: darkColors.borderColor,
    borderBottomWidth: 1,
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingHorizontal: 16,
    paddingVertical: 16,
    width: '100%',
  },
  textContainer: {
    flex: 3,
  },
  text: {
    color: darkColors.textPrimary,
    fontSize: 20,
  },
});

Import Button and Text from @rneui/themed, and StyleSheet, TouchableOpacity, and View from react-native.

Define the styles object using StyleSheet.create.

In React Native, TouchableOpacity is a component that makes any view responsive to touch, similar to how a <button /> tag works in web development with React. When TouchableOpacity is pressed, it decreases the opacity of the wrapped view, providing visual feedback to the user. Hook up your TouchableOpacity to the onPress handler and pass in the id as an argument.

// TODO: record a gif of a simple press to show what the touchable opacity does.

Inside the TouchableOpacity, render a View with a Text component that displays the reminder message. View functions like <div /> in web development. Then, render a Button that displays the delete icon and is connected to the onDeletePressed handler.

Now it's time to TDD the home screen component. As usual, start with a component that renders nothing.

src/features/home/home-screen-component.js
export function HomeScreenComponent() {
  return null;
}

The home screen should take in the list of reminders and the function to delete a reminder.

src/features/home/home-screen-component.test.js
import { render, screen } from '@testing-library/react-native';
 
import { createPopulatedReminder } from '../reminders/reminders-factories';
import { HomeScreenComponent } from './home-screen-component';
 
const createProps = ({
  onDeleteReminder = jest.fn(),
  reminders = [createPopulatedReminder(), createPopulatedReminder()],
} = {}) => ({ onDeleteReminder, reminders });
 
describe('HomeScreenComponent', () => {
  test('given any props: renders the create reminder button', () => {
    const props = createProps();
 
    render(<HomeScreenComponent {...props} />);
 
    expect(
      screen.getByRole('button', { name: /create reminder/i }),
    ).toBeOnTheScreen();
  });
 
  test('given an empty list of reminders: renders the empty state message', () => {
    const props = createProps({ reminders: [] });
 
    render(<HomeScreenComponent {...props} />);
 
    expect(screen.queryByRole('list')).not.toBeOnTheScreen();
    expect(screen.getByText(/you have no reminders yet/i)).toBeOnTheScreen();
  });
 
  test('given some reminders: renders a list of reminders', () => {
    const props = createProps();
 
    render(<HomeScreenComponent {...props} />);
 
    expect(
      screen.queryByText(/you have no reminders yet/i),
    ).not.toBeOnTheScreen();
 
    for (const reminder of props.reminders) {
      expect(screen.getByText(reminder.message)).toBeOnTheScreen();
    }
  });
});

In your createProps function, create an array of reminders using your factory function.

In your first test, check that the "Create Reminder" button is always rendered. Even if no reminders are provided, the user should still be able to create one. You do this by using screen.getByRole to find the button by its accessible name.

In the second test, simulate the empty state by passing an empty reminders array. Use screen.queryByRole to confirm there is no reminder list and then ensure the empty state message is visible using screen.getByText.

For the third test, when reminders are provided, make sure the list is rendered properly. Use a loop to check that each reminder from the reminders array appears on the screen.

Watch your tests fail. Then implement the HomeScreenComponent.

src/features/home/home-screen-component.js
import { Button, Icon } from '@rneui/themed';
import { router } from 'expo-router';
import { Alert, FlatList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
 
import { darkColors } from '../../styles/colors';
import { ReminderItemComponent } from './reminder-item-component';
 
export function HomeScreenComponent({ onDeleteReminder, reminders = [] }) {
  return (
    <>
      <SafeAreaProvider>
        <View style={styles.container}>
          <SafeAreaView>
            {reminders.length > 0 ? (
              <FlatList
                accessibilityRole="list"
                data={reminders}
                renderItem={({ item }) => (
                  <ReminderItemComponent
                    id={item.id}
                    key={item.id}
                    message={item.message}
                    onDeletePressed={() =>
                      Alert.alert(
                        'Delete Alert',
                        'Do you want to delete this reminder',
                        [
                          {
                            text: 'Yes',
                            onPress: () => onDeleteReminder(item.id),
                          },
                          { text: 'No' },
                        ],
                      )
                    }
                    onPress={value =>
                      value.id && router.push(`/reminders/${value.id}`)
                    }
                  />
                )}
              />
            ) : (
              <View style={styles.emptyContainer}>
                <Text style={styles.emptyContainerText}>
                  You have no reminders yet. Click the button below to add a new
                  reminder.
                </Text>
              </View>
            )}
 
            <Button
              containerStyle={styles.reminderButtonContainer}
              onPress={() => router.push('/reminders/create')}
              style={styles.reminderButton}
            >
              <Icon size={25} type="ionicon" name="add-outline" color="white" />
              <Text style={styles.reminderButtonText}>Create Reminder</Text>
            </Button>
          </SafeAreaView>
        </View>
      </SafeAreaProvider>
    </>
  );
}
 
const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    backgroundColor: darkColors.backgroundColorSecondary,
    borderTopColor: darkColors.borderColor,
    borderTopWidth: 1,
    flex: 1,
  },
  reminderButton: {
    color: '#fff',
  },
  reminderButtonContainer: {
    backgroundColor: darkColors.backgroundColorTertiary,
    borderTopColor: darkColors.borderColor,
    borderTopWidth: 1,
    paddingHorizontal: 16,
    paddingTop: 16,
  },
  reminderButtonText: {
    color: '#fff',
    fontSize: 20,
    marginLeft: 4,
  },
  emptyContainer: {
    flex: 1,
    justifyContent: 'center',
  },
  emptyContainerText: {
    color: '#919090',
    fontSize: 20,
    paddingHorizontal: 24,
    textAlign: 'center',
  },
});

Import everything you need, including Alert and FlatList from React Native, then create the styles object.

Alert is a component in React Native used to display an alert dialog to the user, providing a simple way to show a prompt with buttons and capture user responses. You can configure the dialog with options for the title, message, and array of buttons that handle different actions. It's commonly used to confirm user intentions or display critical information.

FlatList is a highly efficient scrolling container that renders a list of data using repeatable rows, similar to rendering lists in React with performance optimizations for mobile platforms.

First, render the container with a FlatList for the reminders. If there are reminders, display them in a list using the ReminderItemComponent. Attach the onDeletePressed handler to show an alert when a user tries to delete a reminder. If the user confirms, call onDeleteReminder to delete it. When the user clicks on a reminder, navigate to its details screen using the reminder's ID.

If there are no reminders, show a message telling the user they have none yet, and include a "Create Reminder" button at the bottom.

Watch your tests pass. Then hook up this component in its container component.

src/features/home/home-screen-container.js
import { compose } from '@reduxjs/toolkit';
import { router } from 'expo-router';
import { connect } from 'react-redux';
 
import { HeaderButton } from '../../components/header-button';
import { withHeader } from '../navigation/with-header';
import { withNotificationPermissions } from '../onboarding/with-notification-permission';
import {
  reminderDeleted,
  selectRemindersArray,
} from '../reminders/reminders-reducer';
import { HomeScreenComponent } from './home-screen-component';
 
const mapStateToProps = state => ({
  reminders: selectRemindersArray(state),
});
 
const mapDispatchToProps = {
  onDeleteReminder: reminderDeleted,
};
 
export const HomeScreenContainer = compose(
  withHeader({
    title: 'Reminders',
    headerLeft: () => (
      <HeaderButton
        iconName="cog-outline"
        onPress={() => router.push('/settings')}
      />
    ),
  }),
  withNotificationPermissions,
  connect(mapStateToProps, mapDispatchToProps),
)(HomeScreenComponent);

Import compose from @reduxjs/toolkit, which allows you to combine multiple higher-order components. Also, import your various HOCs and utility components.

Define mapStateToProps to retrieve the current list of reminders from the state using the selectRemindersArray selector.

Next, create mapDispatchToProps which maps the reminderDeleted action creator to props.

Use compose to wrap HomeScreenComponent with withHeader, withNotificationPermissions, and connect. withHeader configures the top navigation bar, adding a settings button on the left that navigates to the settings page when pressed. withNotificationPermissions redirects the user to the onboarding screen if they haven't granted notification permissions yet. connect binds the Redux state and dispatch actions to your component's props.

Export your HomeScreenContainer from src/app/(main)/home.js.

src/app/(main)/home.js
export { HomeScreenContainer as default } from '../../features/home/home-screen-container';

If you already want to take a peek at this screen before finishing the app, go into the onboarding-screen-container.js and replace the init() function's implementation with a redirect to the home screen.

async function init() {
  router.replace('/home');
}

Note to Cheta or Ibrahim: Make sure you have a recording of both the state with the 4 default reminders for the home screen, as well as the no reminders in state to show the empty message.


Create Reminder Screen

Let's add the ability to create a reminder. Add an action to add a reminder to the state.

src/features/reminders/reminders-reducer.js
import { pipe, prop, values } from 'ramda';
 
import { createReminder } from './reminders-factories';
 
export const sliceName = 'reminders';
 
// ... your reducer
 
export const reminderAdded = newReminder => ({
  type: `${sliceName}/reminderAdded`,
  payload: createReminder(newReminder),
});
 
// ... other action

Import the createReminder factory function and use it in your reminderAdded action to ensure the payload is always a valid reminder. Additionally, when creating a reminder, it's sufficient to pass just the message to the action because createReminder will generate an ID and add the creation date.

Now write the test for the reminderAdded action.

src/features/reminders/reminders-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import { createPopulatedReminder } from './reminders-factories';
import {
  reminderAdded,
  reminderDeleted,
  selectRemindersArray,
} from './reminders-reducer';
 
describe('reminders reducer', () => {
  describe('selectRemindersArray() selector', () => {
    // ... existing tests
 
    test('given a reminder added action: adds a reminder to the array and returns the correct array', () => {
      const reminder = createPopulatedReminder();
      const state = rootReducer(undefined, reminderAdded(reminder));
 
      const actual = selectRemindersArray(state);
      const expected = [
        {
          id: 'hardcoded-first-reminder',
          message: 'You got this! 💪',
          dateCreated: '2024-10-01T00:00:00.000Z',
        },
        {
          id: 'hardcoded-second-reminder',
          message: 'Learn Redux at a senior-level.',
          dateCreated: '2024-10-02T00:00:00.000Z',
        },
        {
          id: 'hardcoded-third-reminder',
          message: 'Give a stranger a compliment 🫂',
          dateCreated: '2024-10-03T00:00:00.000Z',
        },
        {
          id: 'hardcoded-fourth-reminder',
          message: 'Subscribe to Jan Hesters on YouTube!',
          dateCreated: '2024-10-04T00:00:00.000Z',
        },
        reminder,
      ];
 
      expect(actual).toEqual(expected);
    });
  });
});

Create a new reminder and calculate a new state using the reminderAdded action and the root reducer. Then check if the array returned by the selectRemindersArray selector correctly contains the new reminder.

Watch your test fail, then implement the case for the reminderAdded action in your reducer.

src/features/reminders/reminders-reducer.js
// ... your initial state
 
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case reminderAdded().type: {
      return {
        ...state,
        reminders: { ...state.reminders, [payload.id]: payload },
      };
    }
    // ... other cases
  }
};

When handling the reminderAdded action, you need to add the new reminder to the state. You do this by spreading the existing reminders and adding the new reminder to the object using the reminder's ID as the key.

Watch your new test pass.

You need a form to let users enter their reminder messages. This form should also be reusable for editing reminders. The component takes a message and a callback for when the text changes, a label to customize the submit button, and a callback for when the submit button is pressed. By default, the message should be empty, but you can pass an initial message for editing existing reminders.

Create an empty ReminderFormComponent.

src/features/reminders/reminder-form-component.js
export function ReminderFormComponent() {
  return null;
}

Then write your tests for the ReminderFormComponent.

src/features/reminders/reminder-form-component.test.js
import { fireEvent, render, screen } from '@testing-library/react-native';
 
import { ReminderFormComponent } from './reminder-form-component';
import { createPopulatedReminder } from './reminders-factories';
 
const createProps = ({
  onChangeText = jest.fn(),
  onSubmit = jest.fn(),
  ...rest
} = {}) => ({ onChangeText, onSubmit, ...rest });
 
describe('ReminderFormComponent', () => {
  test('given no props: renders a placeholder and a submit button', () => {
    render(<ReminderFormComponent />);
 
    expect(screen.getByRole('button', { name: 'Submit' })).toBeOnTheScreen();
    expect(
      screen.getByPlaceholderText('Write your reminder ...'),
    ).toBeOnTheScreen();
  });
 
  test('given an initial message: renders it in the input', () => {
    const { message } = createPopulatedReminder();
    const props = createProps({ message });
 
    render(<ReminderFormComponent {...props} />);
 
    expect(screen.getByDisplayValue(message)).toBeOnTheScreen();
  });
 
  test('given a submit label: renders the correct label for the button', () => {
    const submitLabel = 'Add Reminder';
    const props = createProps({ submitLabel });
 
    render(<ReminderFormComponent {...props} />);
 
    expect(screen.getByRole('button', { name: submitLabel })).toBeOnTheScreen();
  });
 
  test('given typing into the input: calls the onChangeText listener', () => {
    const props = createProps();
 
    render(<ReminderFormComponent {...props} />);
 
    const message = 'Some new reminder text.';
    fireEvent.changeText(
      screen.getByPlaceholderText('Write your reminder ...'),
      message,
    );
 
    expect(props.onChangeText).toHaveBeenCalled();
  });
 
  test('given pressing the submit button: calls the onSubmit listener and trims leading and trailing whitespace', () => {
    const expectedMessage = 'This is a test.';
    const message = `  ${expectedMessage}   `;
    const props = createProps({ message });
 
    render(<ReminderFormComponent {...props} />);
 
    fireEvent.press(screen.getByRole('button', { name: 'Submit' }));
 
    expect(props.onSubmit).toHaveBeenCalledWith(expectedMessage);
  });
});

Write a createProps function. Your reminder form will two properties, message and submitLabel, that have default values in the component. To avoid overriding them when passing in props created with this factory function, leave them out of the function definition.

In your first test, ensure the form renders correctly with a submit button and an input field, which tests that the correct default values are used and a placeholder is shown.

For your second test, check that when the component is passed a message as props, it is rendered in the input field.

Your third test checks that the submit button's label can be customized via props, which allows you to switch between the "Create Reminder" and "Edit Reminder" labels.

The fourth test should verify that typing in the input calls the onChangeText callback.

Finally, test that pressing the submit button calls the onSubmit prop and that the input text is trimmed of any extra spaces.

Now watch your tests fail, and implement the ReminderFormComponent.

src/features/reminders/reminder-form-component.js
import { Button } from '@rneui/themed';
import {
  KeyboardAvoidingView,
  Platform,
  StyleSheet,
  TextInput,
} from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
 
import { darkColors } from '../../styles/colors';
 
const noop = () => {};
 
export function ReminderFormComponent({
  message = '',
  onChangeText = noop,
  onSubmit = noop,
  submitLabel = 'Submit',
}) {
  return (
    <SafeAreaProvider>
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
        keyboardVerticalOffset={72}
        style={styles.container}
      >
        <SafeAreaView style={styles.safeArea}>
          <TextInput
            cursorColor={darkColors.primaryColor}
            multiline
            numberOfLines={4}
            onChangeText={onChangeText}
            placeholder="Write your reminder ..."
            placeholderTextColor={darkColors.textSecondary}
            style={styles.inputStyles}
            value={message}
          />
 
          <Button
            containerStyle={styles.reminderButtonContainer}
            disabled={!message.trim()}
            onPress={() => onSubmit(message.trim())}
          >
            {submitLabel}
          </Button>
        </SafeAreaView>
      </KeyboardAvoidingView>
    </SafeAreaProvider>
  );
}
 
const styles = StyleSheet.create({
  container: {
    backgroundColor: darkColors.backgroundColorSecondary,
    flex: 1,
  },
  safeArea: {
    borderTopColor: darkColors.borderColor,
    borderTopWidth: 1,
    flex: 1,
    paddingHorizontal: 16,
    paddingTop: 20,
  },
  reminderButtonContainer: {
    backgroundColor: darkColors.backgroundColorTertiary,
    borderTopColor: darkColors.borderColor,
    borderTopWidth: 1,
    paddingHorizontal: 16,
    paddingTop: 16,
    marginHorizontal: -16,
  },
  inputStyles: {
    color: 'white',
    flex: 1,
    flexDirection: 'column',
    fontSize: 20,
  },
});

Import the necessary components, such as the KeyboardAvoidingView and create the styles object for this component.

KeyboardAvoidingView is a component in React Native designed to automatically adjust its position when the keyboard appears, ensuring that the focused input field, such as a text box, is not obscured by the keyboard.

Wrap the KeyboardAvoidingView around the SafeAreaView to ensure the form is properly positioned on iOS. Then configure your TextInput an the submit button. The onPress of the submit button contains the logic to trim the message.

Watch your tests pass.

Then create a ReminderFormContainer component to manage its local state.

src/features/reminders/reminder-form-container.js
import { useState } from 'react';
 
import { ReminderFormComponent } from './reminder-form-component';
 
export const ReminderFormContainer = ({ message, ...props }) => {
  const [currentMessage, setCurrentMessage] = useState(message);
 
  return (
    <ReminderFormComponent
      {...props}
      message={currentMessage}
      onChangeText={setCurrentMessage}
    />
  );
};

Import useState from React and use it to track the current message. Then pass the props to the ReminderFormComponent, binding the message and onChangeText props to the local state.

It's time to create the actual create reminder screen. In this tutorial, it should only render the form, so you're going to use the ReminderFormContainer component in your CreateReminderScreenContainer. But if you wanted to add additional UI elements to the create reminder screen, you could give it its own display component.

src/features/reminders/create-reminder-screen-container.js
import { compose } from '@reduxjs/toolkit';
import { router } from 'expo-router';
import { connect } from 'react-redux';
 
import { withHeader } from '../navigation/with-header';
import { ReminderFormContainer } from './reminder-form-container';
import { createReminder } from './reminders-factories';
import { reminderAdded } from './reminders-reducer';
 
const mapStateToProps = () => ({
  submitLabel: 'Add Reminder',
});
 
const mapDispatchToProps = dispatch => ({
  onSubmit: message => {
    dispatch(reminderAdded(createReminder({ message })));
    router.back();
  },
});
 
export default compose(
  withHeader({ title: 'Add Reminder' }),
  connect(mapStateToProps, mapDispatchToProps),
)(ReminderFormContainer);

Import the various HOCs, components and actions.

Create a mapStateToProps function that sets the submitLabel prop to "Add Reminder". The mini-insight here for you is that you can also use mapStateToProps to set props without using selectors.

Then use mapDispatchToProps to map the reminderAdded action creator to the onSubmit prop. You've usually used the object version of mapDispatchToProps, but here you're using the function version, which allows you to call router.back() after dispatching the reminderAdded action.

Export the CreateReminderScreenContainer from src/app/(main)/reminders/create.js.

src/app/(main)/reminders/create.js
export { default } from '../../../features/reminders/create-reminder-screen-container';

Edit Reminder Screen

Next, you're going to create the edit reminders screen. You need a new action creator and a new selector to get and edit a reminder.

src/features/reminders/reminders-reducer.js
// ... existing actions
 
export const reminderEdited = reminder => ({
  type: `${sliceName}/reminderEdited`,
  payload: reminder,
});
 
// ... existing selectors
 
export const selectReminderById = (state, reminderId) => {};

The reminderEdited action creator should NOT use the createReminder factory function because you're editing an existing reminder, so you need to pass in the entire reminder object to not override existing values. The selectReminderById selector should just do nothing for now.

Implement your tests.

src/features/reminders/reminders-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import { createPopulatedReminder } from './reminders-factories';
import {
  reminderAdded,
  reminderDeleted,
  reminderEdited,
  selectReminderById,
  selectRemindersArray,
} from './reminders-reducer';
 
describe('reminders reducer', () => {
  describe('selectRemindersArray() selector', () => {
    // ... existing tests
 
    test('given a reminder edited action: edits the reminder and returns the correct array', () => {
      const reminder = createPopulatedReminder({
        id: 'hardcoded-second-reminder',
      });
      const state = rootReducer(undefined, reminderEdited(reminder));
 
      const actual = selectRemindersArray(state);
      const expected = [
        {
          id: 'hardcoded-first-reminder',
          message: 'You got this! 💪',
          dateCreated: '2024-10-01T00:00:00.000Z',
        },
        reminder,
        {
          id: 'hardcoded-third-reminder',
          message: 'Give a stranger a compliment 🫂',
          dateCreated: '2024-10-03T00:00:00.000Z',
        },
        {
          id: 'hardcoded-fourth-reminder',
          message: 'Subscribe to Jan Hesters on YouTube!',
          dateCreated: '2024-10-04T00:00:00.000Z',
        },
      ];
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectReminderById() selector', () => {
    test('given a reminder id: returns the correct reminder', () => {
      const reminderId = 'hardcoded-second-reminder';
      const state = rootReducer();
 
      const actual = selectReminderById(state, reminderId);
      const expected = {
        id: 'hardcoded-second-reminder',
        message: 'Learn Redux at a senior-level.',
        dateCreated: '2024-10-02T00:00:00.000Z',
      };
 
      expect(actual).toEqual(expected);
    });
  });
});

Write a test that verifies that the reminderEdited action edits the reminder with the given ID. It follows the general pattern of the other tests you've written.

And then create a new describe block for the selectReminderById selector. Write a test that checks that the selector returns the correct reminder when given a reminder ID.

Watch your tests fail, then implement the reminderEdited action case and the selectReminderById selector.

src/features/reminders/reminders-reducer.js
// ... initial state
 
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    // ... existing cases
    case reminderEdited().type: {
      return {
        ...state,
        reminders: {
          ...state.reminders,
          [payload.id]: { ...state.reminders[payload.id], ...payload },
        },
      };
    }
    default: {
      return state;
    }
  }
};
 
// ... actions
 
const selectRemindersSlice = prop(sliceName);
 
const selectReminders = pipe(selectRemindersSlice, prop('reminders'));
 
export const selectRemindersArray = pipe(selectReminders, values);
 
export const selectReminderById = (state, reminderId) =>
  pipe(selectReminders, prop(reminderId))(state);

For the reminderEdited action case, you need to spread the existing reminders and then add the edited reminder to the object using the reminder's ID as the key.

For the selectReminderById selector, you need to access the reminders object again. You can refactor your selectors to make this easier by breaking out the access to the object into its own selector. Now use that selector in your selectRemindersArray selector. Afterwards, implement the selectReminderById selector by grabbing the reminder from the normalized reminders.

Watch your tests pass. They also verify that the refactor of your selectRemindersArray selector was successful.

In the HomeScreenComponent you navigate to the reminder details screen using router.push(`/reminders/${value.id}`). That means the id of the reminder you want to edit is saved in the URL parameters. Expo only exposes a hook to access them, but in this tutorial you're going to create HOC for that so you can compose your container more elegantly.

src/features/navigation/with-local-search-params.js
import { useLocalSearchParams } from 'expo-router';
 
export const withLocalSearchParams = Component =>
  function WithLocalSearchParams(props) {
    const parameters = useLocalSearchParams();
 
    return <Component {...props} {...parameters} />;
  };

The HOC uses the useLocalSearchParams hook, which returns the URL parameters, and passes them as props to the wrapped component.

Now you can tie everything together in the EditReminderScreenContainer, where you'll also be able to use your ReminderFormContainer again because earlier you set it up where it can be used for both creating and editing reminders.

src/features/reminders/edit-reminder-screen-container.js
import { compose } from '@reduxjs/toolkit';
import { router } from 'expo-router';
import { connect } from 'react-redux';
 
import { withHeader } from '../navigation/with-header';
import { withLocalSearchParams } from '../navigation/with-local-search-params';
import { ReminderFormContainer } from './reminder-form-container';
import { reminderEdited, selectReminderById } from './reminders-reducer';
 
const mapStateToProps = (state, props) => ({
  submitLabel: 'Save Reminder',
  reminder: selectReminderById(state, props.id),
});
 
const mapDispatchToProps = (dispatch, props) => ({
  onSubmit: message => {
    dispatch(reminderEdited({ id: props.id, message }));
    router.back();
  },
});
 
export default compose(
  withHeader({ title: 'Edit Reminder' }),
  withLocalSearchParams,
  connect(mapStateToProps, mapDispatchToProps),
)(ReminderFormContainer);

Import the HOCs, components and actions.

In mapStateToProps, configure the submit button's label and use the selectReminderById selector to get the reminder with the given ID.

Next, in mapDispatchToProps, map the reminderEdited action creator to the onSubmit prop. After dispatching the action, navigate back to the home screen again.

Use compose to wrap ReminderFormContainer with withHeader, withLocalSearchParams, and connect.

Export the EditReminderScreenContainer from src/app/(main)/reminders/[id].js.

src/app/(main)/reminders/[id].js
export { default } from '../../../features/reminders/edit-reminder-screen-container';

Settings Screen

Only two tasks remain to finish the app. First, create a settings screen where users can choose when to receive notifications. Second, add the notification scheduling logic. Start with the settings since you'll need the preferences for scheduling notifications.

Create a src/features/settings/settings-constants.js file to store some hardcoded settings.

src/features/settings/settings-constants.js
export const settingsConstants = {
  /**
   * The maximum number of reminders that can be created per day.
   */
  MAX_REMINDERS_PER_DAY: 24,
  /**
   * The minimum number of reminders that can be created per day.
   */
  MIN_REMINDERS_PER_DAY: 1,
  /**
   * The step size for the slider that controls the number of reminders per day.
   */
  REMINDERS_PER_DAY_STEP: 1,
};

The settingsConstants object contains the maximum and minimum number of reminders per day and the step size for the slider that will control the number of reminders per day.

Start by building the UI for the settings screen, then handle the Redux state. This approach will help you better understand the requirements for and design of the settings slice.

The settings screen will include time pickers for users to choose the start and end times for receiving notifications. Use TDD to develop this component first.

src/features/settings/time-picker-component.js
export function TimePickerComponent() {
  return null;
}

Export a TimePickerComponent that renders nothing.

The time picker should be able to display a title and the current time and have two buttons to increment and decrement the time.

Write your tests for this component.

src/features/settings/time-picker-component.test.js
import { fireEvent, render, screen } from '@testing-library/react-native';
 
import { TimePickerComponent } from './time-picker-component';
 
const createProps = ({
  currentTime = '09:00',
  decreaseLabel = 'decrease day start time',
  decrementButtonEnabled = true,
  increaseLabel = 'increase day start time',
  incrementButtonEnabled = true,
  onDecrementPress = jest.fn(),
  onIncrementPress = jest.fn(),
  title = 'Your day starts at:',
} = {}) => ({
  currentTime,
  decreaseLabel,
  decrementButtonEnabled,
  increaseLabel,
  incrementButtonEnabled,
  onDecrementPress,
  onIncrementPress,
  title,
});
 
describe('TimePickerComponent', () => {
  test('given a title and a current time: renders the title and the current time', () => {
    const props = createProps();
 
    render(<TimePickerComponent {...props} />);
 
    expect(screen.getByText(props.title)).toBeOnTheScreen();
    expect(screen.getByText(props.currentTime)).toBeOnTheScreen();
  });
 
  test('given a pressing the button with the decrease label: triggers the onDecrementPress listener', () => {
    const props = createProps();
 
    render(<TimePickerComponent {...props} />);
 
    const button = screen.getByLabelText(props.decreaseLabel);
    fireEvent.press(button);
 
    expect(props.onDecrementPress).toHaveBeenCalledTimes(1);
  });
 
  test('given a pressing the button with the increase label: triggers the onIncrementPress listener', () => {
    const props = createProps();
 
    render(<TimePickerComponent {...props} />);
 
    const button = screen.getByLabelText(props.increaseLabel);
    fireEvent.press(button);
 
    expect(props.onIncrementPress).toHaveBeenCalledTimes(1);
  });
 
  test('given decrementButtonEnabled as false: disables the decrement button', () => {
    const props = createProps({ decrementButtonEnabled: false });
 
    render(<TimePickerComponent {...props} />);
 
    const button = screen.getByLabelText(props.decreaseLabel);
    expect(button).toBeDisabled();
  });
 
  test('given incrementButtonEnabled as false: disables the increment button', () => {
    const props = createProps({ incrementButtonEnabled: false });
 
    render(<TimePickerComponent {...props} />);
 
    const button = screen.getByLabelText(props.increaseLabel);
    expect(button).toBeDisabled();
  });
});

Create a createProps function to gather the props needed for the functionality we discussed. You'll need the current time, a title, button labels, callbacks for button presses, and options to disable the buttons.

In your first test, use screen.getByText to check that both the title and current time are displayed on the screen.

Next, write a test to ensure that pressing the decrease button triggers the onDecrementPress callback. Simulate a press event using fireEvent.press, and then assert that the onDecrementPress function has been called once.

Similarly, create a test for the increase button. Find the button using screen.getByLabelText with the increaseLabel prop, simulate a press, and check that the onIncrementPress function has been called once.

Then, test that the decrement button is disabled when decrementButtonEnabled is set to false.

Finally, write a similar test for the increment button. Set incrementButtonEnabled to false in the props, render the component, find the increase button, and assert that it is disabled using expect(button).toBeDisabled().

Watch your 5 new tests fail, then implement the TimePickerComponent.

src/features/settings/time-picker-component.js
import { Button, Text } from '@rneui/themed';
import { StyleSheet, View } from 'react-native';
 
const noop = () => {};
 
export function TimePickerComponent({
  currentTime = '00:00',
  decreaseLabel = 'decrease time by one hour',
  decrementButtonEnabled = true,
  increaseLabel = 'increase time by one hour',
  incrementButtonEnabled = true,
  onDecrementPress = noop,
  onIncrementPress = noop,
  title = 'Pick a time:',
}) {
  return (
    <View style={styles.timePickerContainer}>
      <Text h3 h3Style={styles.currentTimeHeading}>
        {title}
      </Text>
 
      <View style={styles.timePickerButtonContainer}>
        <Text h4 h4Style={styles.currentTime}>
          {currentTime}
        </Text>
 
        <Button
          accessibilityLabel={decreaseLabel}
          disabled={!decrementButtonEnabled}
          icon={{ name: 'remove', color: 'white' }}
          onPress={onDecrementPress}
          size="sm"
        />
 
        <Button
          accessibilityLabel={increaseLabel}
          disabled={!incrementButtonEnabled}
          icon={{ name: 'add', color: 'white' }}
          onPress={onIncrementPress}
          size="sm"
        />
      </View>
    </View>
  );
}
 
const styles = StyleSheet.create({
  timePickerContainer: {
    alignItems: 'center',
    flexDirection: 'row',
    justifyContent: 'space-between',
    width: '100%',
  },
  currentTimeHeading: {
    fontSize: 20,
  },
  currentTime: {
    marginRight: 8,
  },
  timePickerButtonContainer: {
    alignItems: 'center',
    flexDirection: 'row',
    gap: 8,
  },
});

Begin by importing the essential components you'll need for your TimePickerComponent. Bring in Button and Text from @rneui/themed to handle your text and button elements. Also, import StyleSheet and View from react-native for styling and layout purposes.

Define basic styles for your containers, text headings, and buttons to ensure the component has a polished appearance using StyleSheet.create.

Inside the TimePickerComponent, structure your layout using View components to group related elements. Use a Text component to display the title and the current time.

Add the decrement and increment buttons using the Button component.

Watch your tests pass.

Now you can create the display component for the settings screen.

src/features/settings/settings-screen-component.js
export function SettingsScreenComponent() {
  return null;
}

Export a SettingsScreenComponent that renders nothing.

The settings screen will be the most complex display component in this tutorial. Here, users can customize their experience by selecting notification times and days. They can also decide how many reminders they receive each day. This component will manage various props, such as dayStartTime and dayEndTime for defining the notification period, and remindersPerDay for setting the number of reminders. Each weekday button requires a specific onPress handler to manage its active state, which you will implement for each day of the week Additionally, the slider for adjusting the number of reminders will use handlers like onSlidingComplete and onSlidingValueChange. In other words, prepare for a lot of props, when you write your tests now.

src/features/settings/settings-screen-component.test.js
import { render, screen } from '@testing-library/react-native';
 
import { SettingsScreenComponent } from './settings-screen-component';
 
const createProps = ({
  allDaysAreActive = false,
  canDecrementDayEndTime = true,
  canDecrementDayStartTime = true,
  canIncrementDayEndTime = true,
  canIncrementDayStartTime = true,
  dayEndTime = '22:00',
  dayStartTime = '08:00',
  onDayEndTimeDecrementPress = jest.fn(),
  onDayEndTimeIncrementPress = jest.fn(),
  onDayStartTimeDecrementPress = jest.fn(),
  onDayStartTimeIncrementPress = jest.fn(),
  mondayIsActive = false,
  tuesdayIsActive = false,
  wednesdayIsActive = false,
  thursdayIsActive = false,
  fridayIsActive = false,
  saturdayIsActive = false,
  sundayIsActive = false,
} = {}) => ({
  allDaysAreActive,
  canDecrementDayEndTime,
  canDecrementDayStartTime,
  canIncrementDayEndTime,
  canIncrementDayStartTime,
  dayEndTime,
  dayStartTime,
  onDayEndTimeDecrementPress,
  onDayEndTimeIncrementPress,
  onDayStartTimeDecrementPress,
  onDayStartTimeIncrementPress,
  mondayIsActive,
  tuesdayIsActive,
  wednesdayIsActive,
  thursdayIsActive,
  fridayIsActive,
  saturdayIsActive,
  sundayIsActive,
});
 
describe('SettingsScreenComponent', () => {
  test('given a day start time and a day end time: renders buttons to increment and decrement the day start and end times', () => {
    const props = createProps();
 
    render(<SettingsScreenComponent {...props} />);
 
    // It renders a time picker for the day start time.
    expect(screen.getByText(/starting at/i)).toBeOnTheScreen();
    expect(screen.getByText(props.dayStartTime)).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /decrease day start time/i }),
    ).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /increase day start time/i }),
    ).toBeOnTheScreen();
 
    // It renders a time picker for the day end time.
    expect(screen.getByText(/ending at/i)).toBeOnTheScreen();
    expect(screen.getByText(props.dayEndTime)).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /decrease day end time/i }),
    ).toBeOnTheScreen();
    expect(
      screen.getByRole('button', { name: /increase day end time/i }),
    ).toBeOnTheScreen();
  });
 
  test('given any props: renders a button to activate each day of the week', () => {
    const props = createProps();
 
    render(<SettingsScreenComponent {...props} />);
 
    expect(screen.getByRole('button', { name: /mon/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /tue/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /wed/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /thu/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /fri/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /sat/i })).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /sun/i })).toBeOnTheScreen();
  });
 
  test('given all days are active: shows a message to the user letting them know that currently all days are active', () => {
    const props = createProps({
      allDaysAreActive: true,
      mondayIsActive: true,
      tuesdayIsActive: true,
      wednesdayIsActive: true,
      thursdayIsActive: true,
      fridayIsActive: true,
      saturdayIsActive: true,
      sundayIsActive: true,
    });
 
    render(<SettingsScreenComponent {...props} />);
 
    expect(
      screen.getByText(/all days are currently active/i),
    ).toBeOnTheScreen();
  });
 
  test('given not all days are currently active: hides the message to the user letting them know that currently all days are active', () => {
    const props = createProps({
      allDaysAreActive: false,
      mondayIsActive: false,
      tuesdayIsActive: true,
      wednesdayIsActive: true,
      thursdayIsActive: true,
      fridayIsActive: true,
      saturdayIsActive: true,
      sundayIsActive: true,
    });
 
    render(<SettingsScreenComponent {...props} />);
 
    expect(
      screen.queryByText(/all days are currently active/i),
    ).not.toBeOnTheScreen();
  });
});

Start by creating a createProps function. This function will help you manage the numerous props of the SettingsScreenComponent. Include props like dayStartTime, dayEndTime, flags for enabling or disabling buttons, and active states for each day of the week.

In your first test, verify that when given a dayStartTime and dayEndTime, the component renders the appropriate buttons to increment and decrement these times. Use screen.getByText to confirm that the titles like "Starting at" and "Ending at" are visible, along with the actual times provided. Then, use screen.getByRole to ensure that the increment and decrement buttons for both times are present on the screen.

Next, write a test to confirm that the component always renders buttons to activate each day of the week, regardless of the props. Use screen.getByRole with the name option set to a regular expression that matches the days of the week.

Then, test the scenario where all days are active. Adjust your createProps function to set allDaysAreActive to true and each day's active state to true and verify that a message like "All days are currently active" is displayed.

After that, create a test for when only some days are active. Set allDaysAreActive to false and selectively set some days as active or inactive and confirm that the "All days are currently active" message is not displayed.

Watch your tests fail. Then make them pass by implementing the SettingsScreenComponent.

src/features/settings/settings-screen-component.js
import { Button, Slider, Text } from '@rneui/themed';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
 
import { darkColors } from '../../styles/colors';
import { settingsConstants } from './settings-constants';
import { TimePickerComponent } from './time-picker-component';
 
const noop = () => {};
 
function WeekdayButton({ isActive, ...props }) {
  return (
    <Button
      buttonStyle={isActive ? styles.weekdayButton : styles.secondaryButton}
      size="sm"
      titleStyle={styles.weekdayButtonTitle}
      {...props}
    />
  );
}
 
function Heading({ children }) {
  return (
    <View style={styles.headingContainer}>
      <Text h4 h4Style={styles.h4Style} style={styles.heading}>
        {children}
      </Text>
    </View>
  );
}
 
export function SettingsScreenComponent({
  allDaysAreActive = false,
  canDecrementDayEndTime = true,
  canDecrementDayStartTime = true,
  canIncrementDayEndTime = true,
  canIncrementDayStartTime = true,
  dayEndTime = '22:00',
  dayStartTime = '08:00',
  onDayEndTimeDecrementPress = noop,
  onDayEndTimeIncrementPress = noop,
  onDayStartTimeDecrementPress = noop,
  onDayStartTimeIncrementPress = noop,
  mondayIsActive = false,
  tuesdayIsActive = false,
  wednesdayIsActive = false,
  thursdayIsActive = false,
  fridayIsActive = false,
  saturdayIsActive = false,
  sundayIsActive = false,
  onSlidingComplete = noop,
  onSlidingValueChange = noop,
  onToggleMondayIsActive = noop,
  onToggleTuesdayIsActive = noop,
  onToggleWednesdayIsActive = noop,
  onToggleThursdayIsActive = noop,
  onToggleFridayIsActive = noop,
  onToggleSaturdayIsActive = noop,
  onToggleSundayIsActive = noop,
  remindersPerDay = 1,
}) {
  return (
    <SafeAreaProvider>
      <View style={styles.container}>
        <StatusBar style="light" />
 
        <SafeAreaView style={styles.safeArea}>
          <Heading>When should reminders be sent?</Heading>
 
          <View style={styles.timePickerContainer}>
            <TimePickerComponent
              currentTime={dayStartTime}
              decreaseLabel="decrease day start time by one hour"
              decrementButtonEnabled={canDecrementDayStartTime}
              increaseLabel="increase day start time by one hour"
              incrementButtonEnabled={canIncrementDayStartTime}
              onDecrementPress={onDayStartTimeDecrementPress}
              onIncrementPress={onDayStartTimeIncrementPress}
              title="Starting at:"
            />
 
            <TimePickerComponent
              currentTime={dayEndTime}
              decreaseLabel="decrease day end time by one hour"
              decrementButtonEnabled={canDecrementDayEndTime}
              increaseLabel="increase day end time by one hour"
              incrementButtonEnabled={canIncrementDayEndTime}
              onDecrementPress={onDayEndTimeDecrementPress}
              onIncrementPress={onDayEndTimeIncrementPress}
              title="Ending at:"
            />
 
            <Text style={styles.miniInfo}>
              Timezone is local to your current location.
            </Text>
          </View>
 
          <Heading>Which days should reminders be sent?</Heading>
 
          <View style={styles.weekdayButtonContainer}>
            <WeekdayButton
              onPress={onToggleMondayIsActive}
              title="Mon"
              isActive={mondayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleTuesdayIsActive}
              title="Tue"
              isActive={tuesdayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleWednesdayIsActive}
              title="Wed"
              isActive={wednesdayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleThursdayIsActive}
              title="Thu"
              isActive={thursdayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleFridayIsActive}
              title="Fri"
              isActive={fridayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleSaturdayIsActive}
              title="Sat"
              isActive={saturdayIsActive}
            />
 
            <WeekdayButton
              onPress={onToggleSundayIsActive}
              title="Sun"
              isActive={sundayIsActive}
            />
          </View>
 
          <View style={styles.sliderContainer}>
            {allDaysAreActive && (
              <Text style={styles.miniInfo}>
                All days are currently active.
              </Text>
            )}
 
            <Text style={{ fontSize: 20 }}>
              Reminders per day: {remindersPerDay}
            </Text>
 
            <Slider
              maximumValue={settingsConstants.MAX_REMINDERS_PER_DAY}
              minimumValue={settingsConstants.MIN_REMINDERS_PER_DAY}
              onSlidingComplete={onSlidingComplete}
              onValueChange={onSlidingValueChange}
              step={settingsConstants.REMINDERS_PER_DAY_STEP}
              value={remindersPerDay}
            />
          </View>
        </SafeAreaView>
      </View>
    </SafeAreaProvider>
  );
}
 
const styles = StyleSheet.create({
  container: {
    backgroundColor: darkColors.backgroundColorSecondary,
    flex: 1,
  },
  safeArea: {
    flex: 1,
    paddingHorizontal: 16,
  },
  headingContainer: {
    backgroundColor: darkColors.backgroundColorTertiary,
    borderBottomColor: darkColors.borderColor,
    borderBottomWidth: 1,
    borderTopColor: darkColors.borderColor,
    borderTopWidth: 1,
    marginHorizontal: -16,
  },
  heading: {
    paddingHorizontal: 16,
    paddingVertical: 20,
  },
  h4Style: {
    fontSize: 20,
  },
  timePickerContainer: {
    display: 'flex',
    flexDirection: 'column',
    gap: 16,
    paddingVertical: 20,
  },
  sliderContainer: {
    flex: 1,
    flexDirection: 'column',
    gap: 16,
    paddingVertical: 20,
    width: '100%',
  },
  weekdayButtonContainer: {
    flexDirection: 'row',
    justifyContent: 'center',
    gap: 4,
    paddingVertical: 20,
  },
  weekdayButtonTitle: {
    fontSize: 12,
    width: 32,
    textTransform: 'uppercase',
  },
  weekdayButton: {
    borderColor: darkColors.backgroundColorPrimary,
    borderRadius: 8,
    borderWidth: 1,
  },
  secondaryButton: {
    backgroundColor: darkColors.secondary,
    borderColor: darkColors.borderColor,
    borderWidth: 1,
    borderRadius: 8,
  },
  miniInfo: {
    textAlign: 'right',
    fontSize: 12,
  },
});

First, import all the necessary components for your SettingsScreenComponent. Afterwards, define your styles object using StyleSheet.create.

One new component is the Slider component. It provides a user interface element that allows users to select a value from a continuous or fixed range by sliding a thumb horizontally.

Then, create two helper components: First, the WeekdayButton, which is a custom toggle button that changes appearance based on whether a day is active. And second, the Heading component, which is a simple wrapper around a View and a Text element, styled to act as a section header within your settings screen.

Now implement the SettingsScreenComponent. It takes in all the props you defined in your tests, and the props for the slider. You didn't write any tests for the slider because fireEvent doesn't expose any drag events.

Begin the component by wrapping everything inside SafeAreaProvider and SafeAreaView to ensure your content is displayed within the safe area boundaries of different devices.

Render a a Heading that prompts the user with "When should reminders be sent?". Below this, include two instances of your TimePickerComponent. One for the day start time and one for the day end time. Add a small Text element to inform users that the timezone is local to their current location.

Next, include another Heading that asks, "Which days should reminders be sent?". Use the WeekdayButton component for each day of the week, passing in props like onPress handlers to toggle the active state and the isActive flag to reflect the current state.

Lastly, create a View for the slider component. If allDaysAreActive is true, display a message informing the user that all days are currently active. Below that, add a Text element to show how many reminders per day are set, and include a Slider component to allow users to adjust this number. Set the slider's minimum and maximum values using settingsConstants, and attach the appropriate handlers for when the slider value changes or when sliding is complete.

Now watch your tests pass.

Before you can write the container component, you need to take care of the settings slice. Based on the settings screen that you just created, you can think about what you need to track in your Redux state:

  • You need to track which days are active.
  • You need to track the start and end time.
  • And you need to know how many reminders per day the user wants to receive.

When you know that the state of a reducer gets complex, it can be a good idea to create factory functions for the initial and a populated state. You can use these functions to set up your tests.

src/features/settings/settings-factories.js
import { faker } from '@faker-js/faker';
 
import { settingsConstants } from './settings-constants';
 
export const createSettingsState = ({
  dayStartTime = '08:00',
  dayEndTime = '22:00',
  mondayIsActive = true,
  tuesdayIsActive = true,
  wednesdayIsActive = true,
  thursdayIsActive = true,
  fridayIsActive = true,
  saturdayIsActive = true,
  sundayIsActive = true,
  remindersPerDay = 12,
} = {}) => ({
  dayStartTime,
  dayEndTime,
  mondayIsActive,
  tuesdayIsActive,
  wednesdayIsActive,
  thursdayIsActive,
  fridayIsActive,
  saturdayIsActive,
  sundayIsActive,
  remindersPerDay,
});
 
export const createPopulatedSettingsState = ({
  dayStartTime = faker.number
    .int({ min: 0, max: 10 })
    .toString()
    .padStart(2, '0') + ':00',
  dayEndTime = faker.number
    .int({ min: 11, max: 23 })
    .toString()
    .padStart(2, '0') + ':00',
  mondayIsActive = faker.datatype.boolean(),
  tuesdayIsActive = faker.datatype.boolean(),
  wednesdayIsActive = faker.datatype.boolean(),
  thursdayIsActive = faker.datatype.boolean(),
  fridayIsActive = faker.datatype.boolean(),
  saturdayIsActive = faker.datatype.boolean(),
  sundayIsActive = faker.datatype.boolean(),
  remindersPerDay = faker.number.int({
    min: settingsConstants.MIN_REMINDERS_PER_DAY,
    max: settingsConstants.MAX_REMINDERS_PER_DAY,
  }),
} = {}) =>
  createSettingsState({
    dayStartTime,
    dayEndTime,
    mondayIsActive,
    tuesdayIsActive,
    wednesdayIsActive,
    thursdayIsActive,
    fridayIsActive,
    saturdayIsActive,
    sundayIsActive,
    remindersPerDay,
  });

Create a factory function for the initial state to track all the data stored in the settings slice, with sensible default values.

Use faker again to generate valid random values for all properties in the createPopulatedSettingsState factory function. Remember, that the minimum reminders are 1 and the maximum is 24.

In both factories, and therefore in your state, dayStartTime and dayEndTime are set to a HH:mm string.

src/features/settings/settings-reducer.js
import { createSettingsState } from './settings-factories';
 
export const sliceName = 'settings';
 
const initialState = createSettingsState();
 
export const reducer = (state = initialState, { type, payload } = {}) => state;

Use your factory function to set up the initial state for the settings slice. And export the settings slice name and reducer.

Hook up the settings reducer in your root reducer.

src/redux/root-reducer.js
import { combineReducers } from '@reduxjs/toolkit';
 
import {
  reducer as remindersReducer,
  sliceName as remindersSliceName,
} from '../features/reminders/reminders-reducer';
import {
  reducer as settingsReducer,
  sliceName as settingsSliceName,
} from '../features/settings/settings-reducer';
 
export const rootReducer = combineReducers({
  [remindersSliceName]: remindersReducer,
  [settingsSliceName]: settingsReducer,
});
 
export const rootState = rootReducer(undefined, { type: '' });

Import the slice name and the reducer and add them to combineReducers.

Start with the tests for toggling the active days. Since you render a button for each day, you can create an action creator for each day under your reducer. Each consists of only a type. And then create a selector to grab the active state of each weekday, which all do nothing for now.

src/features/settings/settings-reducer.js
// ... your reducer
 
export const toggleMondayIsActive = () => ({
  type: `${sliceName}/toggleMondayIsActive`,
});
 
export const toggleTuesdayIsActive = () => ({
  type: `${sliceName}/toggleTuesdayIsActive`,
});
 
export const toggleWednesdayIsActive = () => ({
  type: `${sliceName}/toggleWednesdayIsActive`,
});
 
export const toggleThursdayIsActive = () => ({
  type: `${sliceName}/toggleThursdayIsActive`,
});
 
export const toggleFridayIsActive = () => ({
  type: `${sliceName}/toggleFridayIsActive`,
});
 
export const toggleSaturdayIsActive = () => ({
  type: `${sliceName}/toggleSaturdayIsActive`,
});
 
export const toggleSundayIsActive = () => ({
  type: `${sliceName}/toggleSundayIsActive`,
});
 
export const selectMondayIsActive = () => {};
 
export const selectTuesdayIsActive = () => {};
 
export const selectWednesdayIsActive = () => {};
 
export const selectThursdayIsActive = () => {};
 
export const selectFridayIsActive = () => {};
 
export const selectSaturdayIsActive = () => {};
 
export const selectSundayIsActive = () => {};

Then you can write the tests for the action creators and the selectors.


**Note to Cheta or Ibrahim:" Import everything first.

Then, first show reveal the tests for the getMondayIsActive selector one by one. I'll explain that one it detail. Aftew that, you can relaxedly reveal the other tests as whole describe blocks. (Make sure each describe block for all selectors fits the screen.)

--

src/features/settings/settings-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import {
  selectFridayIsActive,
  selectMondayIsActive,
  selectSaturdayIsActive,
  selectSundayIsActive,
  selectThursdayIsActive,
  selectTuesdayIsActive,
  selectWednesdayIsActive,
  toggleFridayIsActive,
  toggleMondayIsActive,
  toggleSaturdayIsActive,
  toggleSundayIsActive,
  toggleThursdayIsActive,
  toggleTuesdayIsActive,
  toggleWednesdayIsActive,
} from './settings-reducer';
 
describe('settings reducer', () => {
  describe('selectMondayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectMondayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given monday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleMondayIsActive());
 
      const actual = selectMondayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given monday is activated again: returns true', () => {
      const actions = [toggleMondayIsActive(), toggleMondayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectMondayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectTuesdayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectTuesdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given tuesday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleTuesdayIsActive());
 
      const actual = selectTuesdayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given tuesday is activated again: returns true', () => {
      const actions = [toggleTuesdayIsActive(), toggleTuesdayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectTuesdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectWednesdayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectWednesdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given wednesday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleWednesdayIsActive());
 
      const actual = selectWednesdayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given wednesday is activated again: returns true', () => {
      const actions = [toggleWednesdayIsActive(), toggleWednesdayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectWednesdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectThursdayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectThursdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given thursday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleThursdayIsActive());
 
      const actual = selectThursdayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given thursday is activated again: returns true', () => {
      const actions = [toggleThursdayIsActive(), toggleThursdayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectThursdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectFridayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectFridayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given friday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleFridayIsActive());
 
      const actual = selectFridayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given friday is activated again: returns true', () => {
      const actions = [toggleFridayIsActive(), toggleFridayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectFridayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectSaturdayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectSaturdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given saturday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleSaturdayIsActive());
 
      const actual = selectSaturdayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given saturday is activated again: returns true', () => {
      const actions = [toggleSaturdayIsActive(), toggleSaturdayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectSaturdayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectSundayIsActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectSundayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given sunday is deactivated: returns false', () => {
      const state = rootReducer(undefined, toggleSundayIsActive());
 
      const actual = selectSundayIsActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given sunday is activated again: returns true', () => {
      const actions = [toggleSundayIsActive(), toggleSundayIsActive()];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectSundayIsActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
  });
});

Write the tests for the selectMondayIsActive selector.

For the initial state, verify that the selector returns true because by default all days are active.

When you dispatched the toggle action once, it should return false.

And when you dispatch the toggle action twice, it should return true again. Here, you use the technique mentioned earlier, where you reduce over an array of actions to see how multiple actions interact with each other. You're going to use this technique many times in the rest of this tutorial.

These three tests together verify that the selector and action creator work as expected.

Now you can write the same set of tests for Tuesday, Wednesday, Thursday, Friday, Saturday, and Sunday.

Run your tests and watch all 21 of them fail.

Now implement the case handlers in your reducer and the selectors.

src/features/settings/settings-reducer.js
import { pipe, prop } from 'ramda';
 
// ... your initial state
 
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case toggleMondayIsActive().type: {
      return { ...state, mondayIsActive: !state.mondayIsActive };
    }
 
    case toggleTuesdayIsActive().type: {
      return { ...state, tuesdayIsActive: !state.tuesdayIsActive };
    }
 
    case toggleWednesdayIsActive().type: {
      return { ...state, wednesdayIsActive: !state.wednesdayIsActive };
    }
 
    case toggleThursdayIsActive().type: {
      return { ...state, thursdayIsActive: !state.thursdayIsActive };
    }
 
    case toggleFridayIsActive().type: {
      return { ...state, fridayIsActive: !state.fridayIsActive };
    }
 
    case toggleSaturdayIsActive().type: {
      return { ...state, saturdayIsActive: !state.saturdayIsActive };
    }
 
    case toggleSundayIsActive().type: {
      return { ...state, sundayIsActive: !state.sundayIsActive };
    }
 
    default: {
      return state;
    }
  }
};
 
// ... your actions
 
const selectSettingsSlice = prop(sliceName);
 
export const selectMondayIsActive = pipe(
  selectSettingsSlice,
  prop('mondayIsActive'),
);
 
export const selectTuesdayIsActive = pipe(
  selectSettingsSlice,
  prop('tuesdayIsActive'),
);
 
export const selectWednesdayIsActive = pipe(
  selectSettingsSlice,
  prop('wednesdayIsActive'),
);
 
export const selectThursdayIsActive = pipe(
  selectSettingsSlice,
  prop('thursdayIsActive'),
);
 
export const selectFridayIsActive = pipe(
  selectSettingsSlice,
  prop('fridayIsActive'),
);
 
export const selectSaturdayIsActive = pipe(
  selectSettingsSlice,
  prop('saturdayIsActive'),
);
 
export const selectSundayIsActive = pipe(
  selectSettingsSlice,
  prop('sundayIsActive'),
);

To handle the toggleMondayIsActive action creator, you can simply set mondayIsActive to the opposite of its current value.

Do the same for Tuesday, Wednesday, Thursday, Friday, Saturday, and Sunday.

Create a selector that grabs the settings slice using prop from Ramda.

Then, use pipe and prop to compose the selectors that grab each week's active state.

Next, handle the case for when the amount of reminders per day are picked.

src/features/settings/settings-reducer.js
// ... other action creators
 
export const remindersPerDayPicked = payload => ({
  payload,
  type: `${sliceName}/remindersPerDayPicked`,
});
 
// ... other selectors
 
export const selectRemindersPerDay = () => {};

Create the action creator for the remindersPerDayPicked action and a selectRemindersPerDay that does nothing.

Then, write your tests for both.

src/features/settings/settings-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import { createPopulatedSettingsState } from './settings-factories';
import {
  remindersPerDayPicked,
  selectFridayIsActive,
  selectMondayIsActive,
  selectRemindersPerDay,
  // ...
} from './settings-reducer';
 
describe('settings reducer', () => {
  // ... other selector tests
 
  describe('selectRemindersPerDay() selector', () => {
    test('given initial state: returns 12', () => {
      const state = rootReducer();
 
      const actual = selectRemindersPerDay(state);
      const expected = 12;
 
      expect(actual).toEqual(expected);
    });
 
    test('given a reminders per day picked action: sets the reminders per day', () => {
      const remindersPerDay = createPopulatedSettingsState().remindersPerDay;
      const state = rootReducer(
        undefined,
        remindersPerDayPicked(remindersPerDay),
      );
 
      const actual = selectRemindersPerDay(state);
      const expected = remindersPerDay;
 
      expect(actual).toEqual(expected);
    });
  });
});

Import the createPopulatedSettingsState factory together with the remindersPerDayPicked action creator and the selectRemindersPerDay selector.

In the first test, verify that selectRemindersPerDay returns 12 for the initial state because the app should show 12 reminders per day by default.

For the second test, set up a state with a custom amount of reminders per day and verify that selectRemindersPerDay returns that amount.

Run your tests and watch them fail.

Subsequently, implement the case handler and selector.

src/features/settings/settings-reducer.js
// ... your initial state
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    // ... other cases
 
    case remindersPerDayPicked().type: {
      return { ...state, remindersPerDay: payload };
    }
 
    // ...
  }
};
 
// ... other action creators and selectors
 
export const selectRemindersPerDay = pipe(
  selectSettingsSlice,
  prop('remindersPerDay'),
);

The case handle for the remindersPerDayPicked action creator simply sets the remindersPerDay property in the settings slice to the payload of the action.

And the selectRemindersPerDay selector uses pipe and prop to get the remindersPerDay property from the settings slice.

Run your tests and watch them pass.

Let's add the selector that aggregates if all days are active or not. Add a selectAllDaysAreCurrentlyActive that does nothing.

src/features/settings/settings-reducer.js
// ... other selectors
 
export const selectAllDaysAreCurrentlyActive = () => {};

Then write the tests.

src/features/settings/settings-reducer.test.js
// ... other imports
import {
  // ...
  selectAllDaysAreCurrentlyActive,
  // ...
} from './settings-reducer';
 
describe('settings reducer', () => {
  // ... other selector tests
 
  describe('selectAllDaysAreCurrentlyActive() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectAllDaysAreCurrentlyActive(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given all days are deactivated: returns false', () => {
      const actions = [
        toggleMondayIsActive(),
        toggleTuesdayIsActive(),
        toggleWednesdayIsActive(),
        toggleThursdayIsActive(),
        toggleFridayIsActive(),
        toggleSaturdayIsActive(),
        toggleSundayIsActive(),
      ];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectAllDaysAreCurrentlyActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
 
    test('given some days are activated (e.g. "monday", "wednesday", "friday"): returns false', () => {
      const actions = [
        toggleTuesdayIsActive(),
        toggleThursdayIsActive(),
        toggleSaturdayIsActive(),
        toggleSundayIsActive(),
      ];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectAllDaysAreCurrentlyActive(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
  });
});

By default all days should be active, so the selector selectAllDaysAreCurrentlyActive should return true.

For your second test, dispatch all toggle actions once, so that all days are deactivated. Now the selector should return false.

In your third test, dispatch some of the toggle actions, so that some days are activated. Now the selector should return false again.

Run your tests and watch them fail.

Now you can use juxt, all and equals from Ramda to implement the selector.

src/features/settings/settings-reducer.js
import { all, equals, juxt, pipe, prop } from 'ramda';
 
// ... other selectors
 
export const selectAllDaysAreCurrentlyActive = pipe(
  juxt([
    selectSundayIsActive,
    selectMondayIsActive,
    selectTuesdayIsActive,
    selectWednesdayIsActive,
    selectThursdayIsActive,
    selectFridayIsActive,
    selectSaturdayIsActive,
  ]),
  all(equals(true)),
);

juxt is curried. It first takes an array of functions and then a value. It calls all functions with that value and returns an array with the results. all takes a predicate and a list and returns true only if the predicate returns true for all elements in the list. And equals(true) is a predicate that checks if a value is true.

Together with the previous selectors, selectAllDaysAreCurrentlyActive calculates the result of each selector and returns true only if all results are true.

With this implementation, your tests should pass.

Okay, now think about the last actions and selectors that you need for the settings screen.

The day start time and end time is saved in HH:mm format. And you have to buttons per time that should increment and decrement the time by one hour and can be disabled. To implement the changing of the time, it would be nice if there was a simple function that takes a time string and returns the next hour or previous hour.

Remember, when you do TDD it's simple to just create functions with your dream API that would make your next implementation easier.

Let's TDD these helper functions and export two empty functions from a settings-helpers.js file.

src/features/settings/settings-helpers.js
export const incrementTime = () => {};
 
export const decrementTime = () => {};

Now write tests for the helpers.

src/features/settings/settings-helpers.test.js
import { decrementTime, incrementTime } from './settings-helpers';
 
describe('decrementTime()', () => {
  test.each([
    { time: '00:00', expected: '23:00' },
    { time: '09:00', expected: '08:00' },
    { time: '12:00', expected: '11:00' },
    { time: '14:21', expected: '13:21' },
    { time: '23:00', expected: '22:00' },
  ])('given a time $time: decrements it by one hour', ({ time, expected }) => {
    const actual = decrementTime(time);
 
    expect(actual).toEqual(expected);
  });
});
 
describe('incrementTime()', () => {
  test.each([
    { time: '00:00', expected: '01:00' },
    { time: '09:00', expected: '10:00' },
    { time: '12:00', expected: '13:00' },
    { time: '14:21', expected: '15:21' },
    { time: '23:00', expected: '00:00' },
  ])('given a time $time: increments it by one hour', ({ time, expected }) => {
    const actual = incrementTime(time);
 
    expect(actual).toEqual(expected);
  });
});

Create tests for the decrementTime helper that check if all the different times are decremented as you'd expect. You can easily do this again using the test.each method.

Afterwards, do the same for the incrementTime helper. In these tests, pay attention to edge cases like midnight.

Watch your tests fail, then use date-fns to implement the helpers.

src/features/settings/settings-helpers.js
import { addHours, format, parse, subHours } from 'date-fns';
 
/**
 * Parses a time string into a Date object.
 *
 * @param {string} timeStr - The time string in 'HH:mm' format.
 * @returns {Date} - The Date object.
 */
const parseTime = timeString => parse(timeString, 'HH:mm', new Date());
 
/**
 * Increments the hour in a given time string.
 *
 * @param {string} timeStr - The time string in 'HH:mm' format.
 * @returns {string} - The incremented time string.
 */
export const incrementTime = timeString =>
  format(addHours(parseTime(timeString), 1), 'HH:mm');
 
/**
 * Decrements the hour in a given time string.
 *
 * @param {string} timeStr - The time string in 'HH:mm' format.
 * @returns {string} - The decremented time string.
 */
export const decrementTime = timeString =>
  format(subHours(parseTime(timeString), 1), 'HH:mm');

First, create a parseTime helper that parses a time string into a Date object. You need this because all date-fns functions expect a Date object.

Then, implement the incrementTime and decrementTime helpers. You can use addHours and subHours from date-fns to increment and decrement the time. Lastly, use format to return a string in HH:mm format.

Run your tests and watch them pass.

Now back in your settings reducer, implement action creators to increment and decrement the day start and end times. Additionally, add empty selectors to retrieve the day start and end times and whether the times can be incremented or decremented.

src/features/settings/settings-reducer.js
// ... other action creators
 
export const incrementDayStartTime = () => ({
  type: `${sliceName}/incrementDayStartTime`,
});
 
export const decrementDayStartTime = () => ({
  type: `${sliceName}/decrementDayStartTime`,
});
 
export const incrementDayEndTime = () => ({
  type: `${sliceName}/incrementDayEndTime`,
});
 
export const decrementDayEndTime = () => ({
  type: `${sliceName}/decrementDayEndTime`,
});
 
// ... other selectors
 
export const selectDayStartTime = () => {};
 
export const selectDayEndTime = () => {};
 
export const selectCanIncrementDayStartTime = () => {};
 
export const selectCanDecrementDayStartTime = () => {};
 
export const selectCanIncrementDayEndTime = () => {};
 
export const selectCanDecrementDayEndTime = () => {};

Normally, you'd probably chunk up the development of these actions creators, selectors and helper functions for the settings even more, but this tutorial is already long enough. What I'm trying to say is, if you end up TDD smaller chunk because you can't look as far into the future, that's fine. It takes a lot of practice to plan ahead and see the whole picture.

Now, write all the tests for these selectors.

src/features/settings/settings-reducer.test.js
// ... other imports
import {
  decrementDayEndTime,
  decrementDayStartTime,
  incrementDayEndTime,
  incrementDayStartTime,
  // ...
  selectCanDecrementDayEndTime,
  selectCanDecrementDayStartTime,
  selectCanIncrementDayEndTime,
  selectCanIncrementDayStartTime,
  selectDayEndTime,
  selectDayStartTime,
  // ...
  sliceName,
  // ...
} from './settings-reducer';
 
describe('settings reducer', () => {
  // ... other selector tests
 
  describe('selectDayStartTime() selector', () => {
    test('given initial state: returns "08:00"', () => {
      const state = rootReducer();
 
      const actual = selectDayStartTime(state);
      const expected = '08:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given an increment day start time action: returns the new start time', () => {
      const state = rootReducer(undefined, incrementDayStartTime());
 
      const actual = selectDayStartTime(state);
      const expected = '09:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given an increment day start time action when the day start time is an hour less than the day end time: leaves the day start time unchanged', () => {
      const state = rootReducer(
        {
          ...rootReducer,
          [sliceName]: createPopulatedSettingsState({
            dayStartTime: '21:00',
            dayEndTime: '22:00',
          }),
        },
        incrementDayStartTime(),
      );
 
      const actual = selectDayStartTime(state);
      const expected = '21:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given a decrement day start time action: returns the new start time', () => {
      const state = rootReducer(undefined, decrementDayStartTime());
 
      const actual = selectDayStartTime(state);
      const expected = '07:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given a decrement day start time action when the day start time is 00:00: returns the current start time', () => {
      const state = rootReducer(
        {
          ...rootReducer,
          [sliceName]: createPopulatedSettingsState({ dayStartTime: '00:00' }),
        },
        decrementDayStartTime(),
      );
 
      const actual = selectDayStartTime(state);
      const expected = '00:00';
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectDayEndTime() selector', () => {
    test('given initial state: returns "22:00"', () => {
      const state = rootReducer();
 
      const actual = selectDayEndTime(state);
      const expected = '22:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given an increment day end time action: returns the new end time', () => {
      const state = rootReducer(undefined, incrementDayEndTime());
 
      const actual = selectDayEndTime(state);
      const expected = '23:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given an increment day end time action when the day end time is "23:00": leaves the day end time unchanged', () => {
      const state = rootReducer(
        {
          ...rootReducer,
          [sliceName]: createPopulatedSettingsState({ dayEndTime: '23:00' }),
        },
        incrementDayEndTime(),
      );
 
      const actual = selectDayEndTime(state);
      const expected = '23:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given an decrement day end time action: returns the new end time', () => {
      const state = rootReducer(undefined, decrementDayEndTime());
 
      const actual = selectDayEndTime(state);
      const expected = '21:00';
 
      expect(actual).toEqual(expected);
    });
 
    test('given a decrement day end time action when the day end time is an hour more than the day start time: leaves the day end time unchanged', () => {
      const state = rootReducer(
        {
          ...rootReducer,
          [sliceName]: createPopulatedSettingsState({
            dayStartTime: '08:00',
            dayEndTime: '09:00',
          }),
        },
        decrementDayEndTime(),
      );
 
      const actual = selectDayEndTime(state);
      const expected = '09:00';
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectCanIncrementDayStartTime() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectCanIncrementDayStartTime(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given the day start time is an hour behind the day end time: returns false', () => {
      const state = rootReducer({
        ...rootReducer(),
        [sliceName]: createPopulatedSettingsState({
          dayStartTime: '21:00',
          dayEndTime: '22:00',
        }),
      });
 
      const actual = selectCanIncrementDayStartTime(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectCanDecrementDayStartTime() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectCanDecrementDayStartTime(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given the day start time is 00:00: returns false', () => {
      const state = rootReducer({
        ...rootReducer(),
        [sliceName]: createPopulatedSettingsState({ dayStartTime: '00:00' }),
      });
 
      const actual = selectCanDecrementDayStartTime(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectCanIncrementDayEndTime() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectCanIncrementDayEndTime(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given the day end time is "23:00": returns false', () => {
      const state = rootReducer({
        ...rootReducer(),
        [sliceName]: createPopulatedSettingsState({ dayEndTime: '23:00' }),
      });
 
      const actual = selectCanIncrementDayEndTime(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
  });
 
  describe('selectCanDecrementDayEndTime() selector', () => {
    test('given initial state: returns true', () => {
      const state = rootReducer();
 
      const actual = selectCanDecrementDayEndTime(state);
      const expected = true;
 
      expect(actual).toEqual(expected);
    });
 
    test('given the day end time is an hour ahead of the day start time: returns false', () => {
      const state = rootReducer({
        ...rootReducer(),
        [sliceName]: createPopulatedSettingsState({
          dayStartTime: '08:00',
          dayEndTime: '09:00',
        }),
      });
 
      const actual = selectCanDecrementDayEndTime(state);
      const expected = false;
 
      expect(actual).toEqual(expected);
    });
  });
});

Import all your new action creators and selectors, and the sliceName constant.

Start with the tests for the selectDayStartTime selector. When called with the initial state, it should return 8 o'clock. After incrementing the day start time once, it should return 9 o'clock. If the day start time is one hour earlier than the day end time, the action should leave the day start time unchanged. When decrementing the day start time from the initial state, it should correctly return 7 AM. And if the day start time is already at midnight, the decrement action should do nothing.

For the selectDayEndTime selector, you need very similar tests. The end time should be 10pm for the initial state. After incrementing the day end time once, it should return 11pm. If the day end time is already 23 o'clock, the increment action should do nothing. When decrementing the day end time from the initial state, it should correctly return 9pm. And if the day end time is already one hour later than the start time, the decrement action should do nothing.

The first test for the selectCanIncrementDayStartTime selector should return true for the initial state. And if the day start time is one hour earlier than the day end time, the selector should return false.

For the selectCanDecrementDayStartTime selector, if the day start time is already at midnight, the selector should return false. Otherwise, it should return true.

The inverse is true for the selectCanIncrementDayEndTime and selectCanDecrementDayEndTime selectors. The former should return true for the initial state. And if the day end time is already 23 o'clock, the selectCanIncrementDayEndTime should return false.

While the latter should return true for the initial state. And if the day end time is an hour ahead of the day start time, the selectCanDecrementDayEndTime should return false.

Run your tests and watch them fail.

Implement the case handlers and the selectors to make your tests pass.

src/features/settings/settings-reducer.js
import { all, complement, converge, equals, juxt, pipe, prop } from 'ramda';
 
import { createSettingsState } from './settings-factories';
import { decrementTime, incrementTime } from './settings-helpers';
 
// ...
 
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case incrementDayStartTime().type: {
      const newStartTime = incrementTime(state.dayStartTime);
 
      if (newStartTime !== state.dayEndTime) {
        return { ...state, dayStartTime: newStartTime };
      }
 
      return state;
    }
 
    case decrementDayStartTime().type: {
      if (state.dayStartTime === '00:00') {
        return state;
      }
 
      return { ...state, dayStartTime: decrementTime(state.dayStartTime) };
    }
 
    case incrementDayEndTime().type: {
      if (state.dayEndTime === '23:00') {
        return state;
      }
 
      return { ...state, dayEndTime: incrementTime(state.dayEndTime) };
    }
 
    case decrementDayEndTime().type: {
      const newEndTime = decrementTime(state.dayEndTime);
 
      if (newEndTime !== state.dayStartTime) {
        return { ...state, dayEndTime: newEndTime };
      }
 
      return state;
    }
 
    // ... other cases
  }
}
 
// ... other action creators and selectors
 
export const selectDayStartTime = pipe(
  selectSettingsSlice,
  prop('dayStartTime'),
);
 
export const selectDayEndTime = pipe(selectSettingsSlice, prop('dayEndTime'));
 
const notEquals = complement(equals);
 
export const selectCanIncrementDayStartTime = converge(notEquals, [
  pipe(selectDayStartTime, incrementTime),
  selectDayEndTime,
]);
 
export const selectCanDecrementDayStartTime = pipe(
  selectDayStartTime,
  notEquals('00:00'),
);
 
export const selectCanIncrementDayEndTime = pipe(
  selectDayEndTime,
  notEquals('23:00'),
);
 
export const selectCanDecrementDayEndTime = converge(notEquals, [
  pipe(selectDayEndTime, decrementTime),
  selectDayStartTime,
]);

Import complement and converge from Ramda and your decrementTime and incrementTime helpers.

For the incrementDayStartTime case handler, first calculate the new start time by calling incrementTime with the current start time. Call incrementTime using the current start time. If this new start time isn't the same as the end time, update the start time in your state. Otherwise, just return the current state, ensuring that the start time remains at least one hour before the end time.

In the decrementDayStartTime function, check if the start time is already midnight ('00:00'). If so, just return the state as is. If not, decrement the start time and update the state accordingly.

For extending the end time, use the incrementDayEndTime. If the end time isn't already 23:00, increase it. If it is, do nothing and keep the state unchanged.

Finally, with decrementDayEndTime, reduce the end time by calling decrementTime. If this new end time doesn't match the start time, update it in the state. If it does match, just maintain the current state, ensuring the end time stays at least one hour after the start time.

To implement the selectDayStartTime selector, use the pipe function to compose a chain of functions where you first access the settings slice of your state using selectSettingsSlice, and then fetch the dayStartTime property.

Similarly, for the selectDayEndTime, compose a pipeline where you access the settings slice and then retrieve the dayEndTime property.

Now, to determine whether the day's start time can be incremented, use the selectCanIncrementDayStartTime selector. Create a function using converge that compares the result of incrementing the start time with the current end time. If these two times are not equal, the start time can be incremented. This basically means that the start time should be at least one hour before the end time.

For checking if the day's start time can be decremented, use selectCanDecrementDayStartTime. Simply check if the current start time is not midnight ('00:00').

Next, define selectCanIncrementDayEndTime to see if the end time can be increased. It checks if the current end time is not already the last possible time of the day, which is '23:00'.

Lastly, for the selectCanDecrementDayEndTime selector, you need to ensure that decrementing the end time does not make it equal to the start time. Use converge to compare the decremented end time with the current start time. If they are not equal, the end time can be decremented.

Run your tests and watch them pass.

Now, you can implement the settings screen container.

src/features/settings/settings-screen-container.js
import { compose } from '@reduxjs/toolkit';
import { useState } from 'react';
import { connect } from 'react-redux';
 
import { withHeader } from '../navigation/with-header';
import {
  decrementDayEndTime,
  decrementDayStartTime,
  incrementDayEndTime,
  incrementDayStartTime,
  remindersPerDayPicked,
  selectAllDaysAreCurrentlyActive,
  selectCanDecrementDayEndTime,
  selectCanDecrementDayStartTime,
  selectCanIncrementDayEndTime,
  selectCanIncrementDayStartTime,
  selectDayEndTime,
  selectDayStartTime,
  selectFridayIsActive,
  selectMondayIsActive,
  selectRemindersPerDay,
  selectSaturdayIsActive,
  selectSundayIsActive,
  selectThursdayIsActive,
  selectTuesdayIsActive,
  selectWednesdayIsActive,
  toggleFridayIsActive,
  toggleMondayIsActive,
  toggleSaturdayIsActive,
  toggleSundayIsActive,
  toggleThursdayIsActive,
  toggleTuesdayIsActive,
  toggleWednesdayIsActive,
} from './settings-reducer';
import { SettingsScreenComponent } from './settings-screen-component';
 
function SettingsScreenContainer({ remindersPerDay, ...props }) {
  const [currentRemindersPerDay, setCurrentRemindersPerDay] =
    useState(remindersPerDay);
 
  return (
    <SettingsScreenComponent
      onSlidingValueChange={setCurrentRemindersPerDay}
      remindersPerDay={currentRemindersPerDay}
      {...props}
    />
  );
}
 
const mapStateToProps = state => ({
  canDecrementDayEndTime: selectCanDecrementDayEndTime(state),
  canDecrementDayStartTime: selectCanDecrementDayStartTime(state),
  canIncrementDayEndTime: selectCanIncrementDayEndTime(state),
  canIncrementDayStartTime: selectCanIncrementDayStartTime(state),
  dayEndTime: selectDayEndTime(state),
  dayStartTime: selectDayStartTime(state),
  mondayIsActive: selectMondayIsActive(state),
  tuesdayIsActive: selectTuesdayIsActive(state),
  wednesdayIsActive: selectWednesdayIsActive(state),
  thursdayIsActive: selectThursdayIsActive(state),
  fridayIsActive: selectFridayIsActive(state),
  saturdayIsActive: selectSaturdayIsActive(state),
  sundayIsActive: selectSundayIsActive(state),
  remindersPerDay: selectRemindersPerDay(state),
  allDaysAreActive: selectAllDaysAreCurrentlyActive(state),
});
 
const mapDispatchToProps = {
  onDayEndTimeDecrementPress: decrementDayEndTime,
  onDayEndTimeIncrementPress: incrementDayEndTime,
  onDayStartTimeDecrementPress: decrementDayStartTime,
  onDayStartTimeIncrementPress: incrementDayStartTime,
  onToggleMondayIsActive: toggleMondayIsActive,
  onToggleTuesdayIsActive: toggleTuesdayIsActive,
  onToggleWednesdayIsActive: toggleWednesdayIsActive,
  onToggleThursdayIsActive: toggleThursdayIsActive,
  onToggleFridayIsActive: toggleFridayIsActive,
  onToggleSaturdayIsActive: toggleSaturdayIsActive,
  onToggleSundayIsActive: toggleSundayIsActive,
  onSlidingComplete: remindersPerDayPicked,
};
 
export default compose(
  withHeader({ title: 'Settings' }),
  connect(mapStateToProps, mapDispatchToProps),
)(SettingsScreenContainer);

Import compose, useState, connect and withHeader as well as all your new action creators and selectors.

Create SettingsScreenContainer that uses useState to manage the local state for the reminders per day slider.

Now hook up all of your selectors in mapStateToProps.

And then map the action creators in mapDispatchToProps.

Finally, compose the withHeader HOC and the connect function and wrap your SettingsScreenContainer with them.

Export your settings screen container from the settings route.

src/app/settings.js
export { default } from '../features/settings/settings-screen-container

Scheduling Notifications

All the UI is done. The only thing missing is actually scheduling the reminders as local notifications.

Let's iterate again over the requirements:

  • The reminders should be scheduled for each active day between the start and end times.
  • It should be random when which reminder is shown.
  • You should exactly schedule as many reminders as picked in the settings.

There are many ways to implement this. You could write a backgroud task that schedules reminders on a given interval. Or you could actually sync the settings with a server and send push notifications.

In this tutorial, you're going to implement this by scheduling all notifications according to the settings whenever a reminder is added, edited or deleted or the user's scheduling preferences change. This will involve side effects and handling a somewhat complex flow. Sagas are perfect for this. In fact, the code will be very simple, once you've broken it down enough.

So, start by writing a empty sagas that will watch all relevant actions to schedule the notifications.

src/features/reminders/reminders-sagas.js
export function* handleScheduleReminders() {}
 
export function* watchScheduleReminders() {}

You're going to use the handler-watcher pattern. The handleScheduleReminders saga will contain the logic to schedule the notifications. The watchScheduleReminders saga will watch all relevant actions and trigger the handleScheduleReminders saga.

Let's write the tests for the watchScheduleReminders saga first. It should trigger the handleScheduleReminders saga whenever the settings change, or a reminder is added, edited or deleted.

src/features/reminders/reminders-sagas.test.js
import { testSaga } from 'redux-saga-test-plan';
 
import {
  decrementDayEndTime,
  decrementDayStartTime,
  incrementDayEndTime,
  incrementDayStartTime,
  remindersPerDayPicked,
  toggleFridayIsActive,
  toggleMondayIsActive,
  toggleSaturdayIsActive,
  toggleSundayIsActive,
  toggleThursdayIsActive,
  toggleTuesdayIsActive,
  toggleWednesdayIsActive,
} from '../settings/settings-reducer';
import {
  reminderAdded,
  reminderDeleted,
  reminderEdited,
} from './reminders-reducer';
import {
  handleScheduleReminders,
  watchScheduleReminders,
} from './reminders-sagas';
 
describe('watchScheduleReminders saga', () => {
  it('debounces multiple actions and calls handleScheduleReminders', () => {
    testSaga(watchScheduleReminders)
      .next()
      .debounce(
        3000,
        [
          incrementDayStartTime().type,
          decrementDayStartTime().type,
          incrementDayEndTime().type,
          decrementDayEndTime().type,
          toggleMondayIsActive().type,
          toggleTuesdayIsActive().type,
          toggleWednesdayIsActive().type,
          toggleThursdayIsActive().type,
          toggleFridayIsActive().type,
          toggleSaturdayIsActive().type,
          toggleSundayIsActive().type,
          remindersPerDayPicked().type,
          reminderAdded().type,
          reminderEdited().type,
          reminderDeleted().type,
        ],
        handleScheduleReminders,
      )
      .next()
      .isDone();
  });
});

Import the testSaga helper from redux-saga-test-plan. It allows you to write tests for sagas. Also import all the action creators you'll need to watch and the sagas.

Write the test for the watchScheduleReminders saga. The saga should use debounce to wait for 3 seconds before triggering the handleScheduleReminders saga after any of the watched actions are dispatched.

Watch your test fail. Then, implement the saga.

src/features/reminders/reminders-sagas.js
import { pluck } from 'ramda';
import { debounce } from 'redux-saga/effects';
 
import {
  decrementDayEndTime,
  decrementDayStartTime,
  incrementDayEndTime,
  incrementDayStartTime,
  remindersPerDayPicked,
  toggleFridayIsActive,
  toggleMondayIsActive,
  toggleSaturdayIsActive,
  toggleSundayIsActive,
  toggleThursdayIsActive,
  toggleTuesdayIsActive,
  toggleWednesdayIsActive,
} from '../settings/settings-reducer';
import {
  reminderAdded,
  reminderDeleted,
  reminderEdited,
} from './reminders-reducer';
 
export function* handleScheduleReminders() {}
 
const actionsToTriggerScheduling = pluck('type', [
  incrementDayStartTime(),
  decrementDayStartTime(),
  incrementDayEndTime(),
  decrementDayEndTime(),
  toggleMondayIsActive(),
  toggleTuesdayIsActive(),
  toggleWednesdayIsActive(),
  toggleThursdayIsActive(),
  toggleFridayIsActive(),
  toggleSaturdayIsActive(),
  toggleSundayIsActive(),
  remindersPerDayPicked(),
  reminderAdded(),
  reminderEdited(),
  reminderDeleted(),
]);
 
export function* watchScheduleReminders() {
  yield debounce(3 * 1000, actionsToTriggerScheduling, handleScheduleReminders);
}

Import all the Redux effects, the pluck helper from Ramda and the debounce effect from Redux Saga.

Use pluck to create the array of action types that will trigger the scheduling.

The watchScheduleReminders saga should yield a debounce effect that will wait for 3 seconds before triggering the handleScheduleReminders saga after any of the watched actions are dispatched.

Now run your tests and watch them pass.

The last piece is the handleScheduleReminders saga. But implementing it is tricky. Here are the steps that you need to do:

  • Cancel all existing scheduled notifications.
  • Grab from the store how many reminders per day should be scheduled.
  • Grab the days when reminders should be scheduled.
  • Assign a random time of the day for each reminder between the start and end time for each active day.
  • Make sure that the number of scheduled notifications is correct.
  • Actually schedule the notifications. (Possibly in parallel, so it's fast.)
  • Handle edge cases. For example, what if the user wants more reminders per day than they have reminders? Should you make sure that each reminder is scheduled at least once?

Cancelling the scheduled notifications is easy because Expo's Notifications module has a cancelAllScheduledNotificationsAsync method.

And there is also a method to schedule a notification called scheduleNotificationAsync. It takes in an object with the notification content and trigger.

{
  "content": { "title": "Random Reminder App", "body": "A reminder example" },
  "trigger": { "repeats": true, "weekday": 2, "hour": 21, "minute": 42 }
}

The trigger object's properties do the following:

  • The repeats property is a boolean. It specifies if the notification needs to occur more than once.
  • weekday is a number that indicates the day of the week when the notification should be shown. In this case, it's 2, which is Monday because weekdays are specified with a number from 1 (Sunday) through 7 (Saturday).
  • hour indicates the exact hour of the day to display the notification. Here it's 21, which is 9 PM.
  • minute denotes the minute within the hour for the notification to appear.

... Hmm, what to do?

Here is where one of the most important principles of software development comes into play.

“For each desired change, make the change easy (warning: this may be hard), then make the easy change” - Kent Beck

It's clear that this change is hard right now. So, how can you make it easy?

With TDD, you can start by envisioning what your ideal API would look like and then work towards implementing it.

In an ideal world, there would be a function that gives you an array of notifications along with their scheduled times, allowing you to easily map over them and schedule each one.

The handleScheduleReminders saga only has access to the Redux root state through the select effect. So, from the perspective of this saga, ideally, this function - the "easiest change" - would be a selector. Let's call it selectReminderNotificationSchedule.

Let's apply this principle again to this selector. What would be the easiest implementation? If the selector could simply grab the reminders and settings from the store and then call another function that creates the schedule, that would make the change easy. Let's call this function createNotificationSchedule.

Create an empty createNotificationSchedule function.

src/features/reminders/reminders-helpers.js
export const createNotificationSchedule = () => {};

Now, write the test for this function.

src/features/reminders/reminders-helpers.test.js
import { createNotificationSchedule } from './reminders-helpers';
 
describe('createNotificationSchedule()', () => {
  test('given a list of reminders, a list of integers for the active days, a start time, an end time and the amount of reminders per day: returns a schedule with the correct amount of reminders per day with random times between the start and end times', () => {
    const mockMath = jest.spyOn(Math, 'random').mockReturnValue(1);
 
    const reminders = [
      {
        id: 'hardcoded-first-reminder',
        message: 'You got this! 💪',
        dateCreated: '2024-10-01T00:00:00.000Z',
      },
      {
        id: 'hardcoded-second-reminder',
        message: 'Learn Redux at a senior-level.',
        dateCreated: '2024-10-02T00:00:00.000Z',
      },
      {
        id: 'hardcoded-third-reminder',
        message: 'Give a stranger a compliment 🫂',
        dateCreated: '2024-10-03T00:00:00.000Z',
      },
      {
        id: 'hardcoded-fourth-reminder',
        message: 'Subscribe to Jan Hesters on YouTube!',
        dateCreated: '2024-10-04T00:00:00.000Z',
      },
    ];
    const activeDays = [1, 7]; // Only Saturday and Sunday are active.
    const startTime = '09:00';
    const endTime = '17:00';
    const remindersPerDay = 2;
 
    const actual = createNotificationSchedule({
      reminders,
      activeDays,
      startTime,
      endTime,
      remindersPerDay,
    });
    const expected = [
      {
        content: {
          title: 'Random Reminder App',
          body: 'You got this! 💪',
        },
        trigger: { repeats: true, weekday: 1, hour: 17, minute: 59 },
      },
      {
        content: {
          title: 'Random Reminder App',
          body: 'Learn Redux at a senior-level.',
        },
        trigger: { repeats: true, weekday: 1, hour: 17, minute: 59 },
      },
      {
        content: {
          title: 'Random Reminder App',
          body: 'You got this! 💪',
        },
        trigger: { repeats: true, weekday: 7, hour: 17, minute: 59 },
      },
      {
        content: {
          title: 'Random Reminder App',
          body: 'Learn Redux at a senior-level.',
        },
        trigger: { repeats: true, weekday: 7, hour: 17, minute: 59 },
      },
    ];
 
    expect(actual).toEqual(expected);
 
    mockMath.mockRestore();
  });
});

Import the createNotificationSchedule function. You're going to use Math.random to generate the randomness for this function. At the beginning of the test, use jest.spyOn to make Math.random deterministic, ensuring the test remains deterministic. The API for this function should specify that it receives an array of reminders, a list of integers representing active days, a start time, an end time, and the number of reminders per day. It should return an array of notification objects with content and trigger properties.

Watch the test fail.

Now to implement this function, apply the "make the easy change" principle again. The implementation for this function would be easy, if you could just map over the active days and for each active day select as many reminders as the user wants and then create notifications for each of them.

This translates to you needing 3 functions. One function to select a given number of random values from an array to pick the as many reminders as the user wants to have per day. Let's call this function selectRandomValues. A second function that gets the messages from the reminders. Let's call it getMessages. And a third function that maps a reminder to a scheduled notification. Let's call it createScheduledNotification.

Create empty functions for all three.

src/features/reminders/reminders-helpers.js
export const selectRandomValues = () => {};
 
export const getMessages = () => {};
 
export const createScheduledNotification = () => {};
 
export const createNotificationSchedule = () => {};

Then, write tests for all of them.

src/features/reminders/reminders-helpers.test.js
import { faker } from '@faker-js/faker';
 
import { createPopulatedSettingsState } from '../settings/settings-factories';
import { createPopulatedReminder } from './reminders-factories';
import {
  createNotificationSchedule,
  createScheduledNotification,
  getMessages,
  selectRandomValues,
} from './reminders-helpers';
 
describe('selectRandomValues()', () => {
  const array = ['foo', 42, { bar: 'qux' }, true, { hello: 'world' }, 1337];
 
  test('given an array and a number of elements: returns an array where each element is also an element in the original array', () => {
    const numberOfElements = faker.number.int({
      min: 1,
      max: array.length * 2,
    });
 
    const actual = selectRandomValues(numberOfElements, array).every(element =>
      array.includes(element),
    );
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is less than the total number of elements in the array: returns that many elements from the array', () => {
    const numberOfElements = 3;
 
    const actual = selectRandomValues(numberOfElements, array).length;
    const expected = numberOfElements;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is less than the total number of elements in the array: returns an array without duplicates', () => {
    const numberOfElements = 3;
 
    const actual = new Set(selectRandomValues(numberOfElements, array)).size;
    const expected = numberOfElements;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is equal to the total number of elements in the array: returns the full array, possibly shuffled', () => {
    const numberOfElements = array.length;
 
    const actual = selectRandomValues(numberOfElements, array).every(element =>
      array.includes(element),
    );
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is bigger than the total number of elements in the array: returns an array with duplicates, and possibly shuffled', () => {
    const numberOfElements = faker.number.int({
      min: array.length + 1,
      max: array.length * 2,
    });
 
    const actual = selectRandomValues(numberOfElements, array).every(element =>
      array.includes(element),
    );
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is bigger than the total number of elements in the array: returns an array with that many elements, containing duplicates, and possibly shuffled', () => {
    const numberOfElements = faker.number.int({
      min: array.length + 1,
      max: array.length * 2,
    });
 
    const actual = selectRandomValues(numberOfElements, array).length;
    const expected = numberOfElements;
 
    expect(actual).toEqual(expected);
  });
 
  test('given an array and a number of elements that is bigger than the total number of elements in the array: returns an array that contains each element of the original array at least once', () => {
    const numberOfElements = faker.number.int({
      min: array.length + 1,
      max: array.length * 2,
    });
    const randomElements = selectRandomValues(numberOfElements, array);
 
    const actual = array.every(element => randomElements.includes(element));
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
});
 
describe('getMessages()', () => {
  test('given a list of reminders: returns a list of messages', () => {
    const reminders = [createPopulatedReminder(), createPopulatedReminder()];
 
    const actual = getMessages(reminders);
    const expected = [reminders[0].message, reminders[1].message];
 
    expect(actual).toEqual(expected);
  });
});
 
describe('createScheduledNotification()', () => {
  test('given a reminder: returns the scheduled notification with populated values for the trigger', () => {
    const reminder = createPopulatedReminder();
    const { dayStartTime, dayEndTime } = createPopulatedSettingsState();
 
    const actual = createScheduledNotification({
      reminder: reminder.message,
      day: faker.number.int({ min: 1, max: 7 }),
      startTime: dayStartTime,
      endTime: dayEndTime,
    });
    const expected = {
      content: { title: 'Random Reminder App', body: reminder.message },
      trigger: {
        repeats: true,
        weekday: expect.any(Number),
        hour: expect.any(Number),
        minute: expect.any(Number),
      },
    };
 
    expect(actual).toMatchObject(expected);
  });
});
 
// ... existing tests

Import the faker package as well as the new helper functions.

Start with the tests for selectRandomValues. Each test should capture a property that the function should have. The function should take in a number, which is how many values should be selected, and an array to select from.

The first test should check that even if the number of elements is bigger than the number of elements in the array, each item in the result is also an item in the original array.

The second test checks that if the number of elements to pick is less that the number of elements in the array, the result has the correct number of elements.

The third test has the same setup, but makes sure that the result doesn't contain duplicates.

In the fourth test, you check that if the number of elements to pick is larger than the number of elements in the array, the result contains duplicates.

The fifth test checks that if the number of elements to pick is larger than the number of elements in the array, the result has the correct number of elements.

In the the sixth and last test you ensure that if the number of elements to pick is larger than the number of elements in the array, the result contains each element of the original array at least once.

For getMessages, write a simple test that checks that given a list of reminders, it returns a list of messages.

For createScheduledNotification, write a test that checks that given a reminder, it returns the scheduled notification with populated values for the trigger. Use expect.any to check that the weekday, hour and minute are numbers together with toMatchObject to check that the trigger object matches the expected structure.

Now watch your tests fail.

Now when you want to implement the function, notice that the only one that's easy to implement is getMessages because you can use Ramda. So, implement that first.

src/features/reminders/reminders-helpers.js
import { pluck } from 'ramda';
 
// ... selectRandomValues
 
/**
 * Extracts the 'message' property from an array of objects.
 *
 * @template T - The type of the elements in the array.
 * @param {Array<{ message: T }>} array - The array of objects containing a
 * 'message' property.
 * @returns {T[]} - An array containing the extracted 'message' values.
 */
const getMessages = pluck("message");
 
// ... createScheduledNotification

Your test for getMessages passes now.

But to implement selectRandomValues and createScheduledNotification, let's use the "make the easy change" principle again.

To make selectRandomValues easy, you'll need two functions: one to randomize the order of an array, and another to produce a random integer. Let's call the shuffle and generateRandomInteger. For crafting the createScheduledNotification function, you need to extract the hour from time strings formatted as HH:mm, and to select a random minute from 0 to 59. Let's call these two functions getHours and getRandomMinute.

Write empty functions for generateRandomInt and getHours. getRandomMinute will be trivial to implement using generateRandomInt. And you can implement shuffle already because it will be implicitly tested in selectRandomValues.

src/features/reminders/reminders-helpers.js
import { pluck, sort } from 'ramda';
 
export const generateRandomInt = () => {};
 
const getRandomMinute = () => {};
 
export const getHours = () => {};
 
/**
 * Shuffles an array randomly.
 *
 * @template T - The type of the elements in the array.
 * @param {T[]} array - The array to shuffle.
 * @returns {T[]} - The shuffled array.
 */
const shuffle = sort(() => Math.random() - 0.5);
 
// ... other helper functions

Write the tests for generateRandomInt and getHours.

src/features/reminders/reminders-helpers.test.js
import {
  // ...
  generateRandomInt,
  getHours,
  // ...
} from './reminders-helpers';
 
describe('generateRandomInt()', () => {
  const start = 3;
  const end = 20;
  const numbers = Array.from({ length: 100 }, () =>
    generateRandomInt(start, end),
  );
 
  test('given a start and end: every number is bigger or equal to the start', () => {
    const actual = numbers.every(number => number >= start);
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
 
  test('given a start and end: every number is smaller or equal to the end', () => {
    const actual = numbers.every(number => number <= end);
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
});
 
describe('getHours()', () => {
  test.each([
    { timeString: '12:00', expected: 12 },
    { timeString: '00:00', expected: 0 },
    { timeString: '23:59', expected: 23 },
    { timeString: '03:00', expected: 3 },
  ])(
    'given a time string $timeString, returns $expected',
    ({ timeString, expected }) => {
      const actual = getHours(timeString);
 
      expect(actual).toEqual(expected);
    },
  );
});
 
// ... existing tests

If you're testing random functions, it's good practice to generate a large number of values and check that the function behaves as expected. So, to test the generateRandomInt function, generate 100 random integegers between a start value (3) and an end value (20). Then check that every number is bigger or equal to the start and smaller or equal to the end.

For getHours, use test.each to check that for variety of given time strings, it returns the expected hour as a number.

Watch your tests fail. Now you can implement the most recent functions.

src/features/reminders/reminders-helpers.js
import { head, pipe, pluck, sort, split } from 'ramda';
 
/**
 * Generates a random integer between the start and end values, both inclusive.
 *
 * @param {number} start - The starting value.
 * @param {number} end - The ending value.
 * @returns {number} - A random integer between the start and end.
 */
export const generateRandomInt = (start, end) =>
  Math.round(Math.random() * (end - start) + start);
 
/**
 * Generates a random minute value between 0 and 59.
 *
 * @returns {number} - A random minute value.
 */
const getRandomMinute = () => generateRandomInt(0, 59);
 
/**
 * Retrieves the hour part from a time string formatted as hh:mm.
 *
 * @param {string} time - The time string from which to extract the hour.
 * @returns {number} - The extracted hour as a number.
 */
export const getHours = pipe(split(':'), head, Number);
 
// ... `shuffle` and other helper functions

Implement generateRandomInt using Math.round and Math.random to generate a random number between the start and end values, both inclusive.

For getRandomMinute, use generateRandomInt to return a random integer between 0 and 59.

And for getHours, use pipe and split to split the time string on the colon, take the first element (the hour) using head, and convert it to a number using the Number constructor.

The tests for generateRandomInt and getHours pass now.

Implement selectRandomValues and createScheduledNotification next.

src/features/reminders/reminders-helpers.js
import { head, pipe, pluck, sort, split, take } from 'ramda';
 
// ... other helpers
 
/**
 * Selects a specified number of random values from the input array. If the
 * number of elements to select is greater than the length of the input array,
 * the array can include duplicates.
 *
 * @template T - The type of the elements in the array.
 * @param {number} numberOfElements - The number of random elements to select.
 * @param {T[]} array - The array from which to select random values.
 * @returns {T[]} - An array containing the selected random values.
 */
export const selectRandomValues = (numberOfElements, array) => {
  const shuffledArray = shuffle(array);
 
  if (numberOfElements <= array.length) {
    return take(numberOfElements, shuffledArray);
  }
 
  const remaining = numberOfElements - array.length;
  const extraElements = Array.from(
    { length: remaining },
    () => array[generateRandomInt(0, array.length - 1)],
  );
 
  return shuffle([...shuffledArray, ...extraElements]);
};
 
// ... getMessages
 
/**
 * Creates a scheduled reminder object based on provided details.
 *
 * @param {Object} options - The details for the reminder.
 * @param {number} options.day - The day of the week for the reminder.
 * @param {string} options.reminder - The content of the reminder.
 * @param {string} options.startTime - The start time for the reminder, formatted as hh:mm.
 * @param {string} options.endTime - The end time for the reminder, formatted as hh:mm.
 * @returns {Object} - A scheduled reminder object.
 */
export const createScheduledNotification = ({
  day,
  reminder,
  startTime,
  endTime,
}) => ({
  content: {
    title: 'Random Reminder App',
    body: reminder,
  },
  trigger: {
    repeats: true,
    weekday: day,
    hour: generateRandomInt(getHours(startTime), getHours(endTime)),
    minute: getRandomMinute(),
  },
});
 
export const createNotificationSchedule = () => {};

Modify selectRandomValues so it creates a shuffledArray using shuffle. If the number of elements to select is less than or equal to the length of the array, use take to get that many elements from the shuffled array. If the number of elements to select is greater than the length of the array, generate enough extra elements to reach that number, using generateRandomInt to pick random indices from the original array. In this case, shuffle the result using shuffle again.

For createScheduledNotification, simply map the reminder to the body of the notification. Then, use generateRandomInt together with getHours to generate a random hour between the start and end times, and use getRandomMinute to generate a random minute.

Your tests for selectRandomValues and createScheduledNotification pass now, too.

Finally, implement your createNotificationSchedule helper.

src/features/reminders/reminders-helpers.js
// ... other helpers
 
/**
 * Creates a schedule of notifications based on the provided details.
 *
 * @param {Object} options - The details for the schedule.
 * @param {Object[]} options.reminders - The array of reminders.
 * @param {number[]} options.activeDays - The active days of the week.
 * @param {string} options.startTime - The start time for the reminders,
 * formatted as hh:mm.
 * @param {string} options.endTime - The end time for the reminders, formatted
 * as hh:mm.
 * @param {number} options.remindersPerDay - The number of reminders per day.
 * @returns {Object[][]} - A two-dimensional array where each sub-array contains
 * scheduled notifications for the reminders for a day.
 */
export const createNotificationSchedule = ({
  reminders,
  activeDays,
  startTime,
  endTime,
  remindersPerDay,
}) =>
  activeDays.flatMap(day =>
    selectRandomValues(remindersPerDay, getMessages(reminders)).map(reminder =>
      createScheduledNotification({ day, reminder, startTime, endTime }),
    ),
  );

To implement the createNotificationSchedule function, start by utilizing flatMap on the activeDays array to process each day in sequence. For each day, select a specific number of reminders by calling selectRandomValues with the number of reminders per day and the array of reminders obtained from getMessages. Then, for each selected reminder, use createScheduledNotification to generate a notification. The use of flatMap ensures that the result is a single-dimensional array of scheduled notifications, rather than an array of arrays.

Now, your test for createNotificationSchedule passes.

You can't quite implement the selectReminderNotificationSchedule selector, yet. You need another selector that turns the active days your user selected into an array of numbers representing the days of the week.

Create an empty selectActiveWeekdayIntegers selector in your settings slice.

src/features/settings/settings-reducer.js
// ... your other selectors
 
export const selectActiveWeekdayIntegers = () => {};

Implement the tests for this selector.

src/features/settings/settings-reducer.test.js
import {
  // ...
  selectActiveWeekdayIntegers,
  // ...
} from './settings-reducer';
 
describe('settings reducer', () => {
  // ... existing selector tests
 
  describe('selectActiveWeekdayIntegers()', () => {
    test('given initial state: returns [1, 2, 3, 4, 5, 6, 7] (1 = sunday, 7 = saturday)', () => {
      const state = rootReducer();
 
      const actual = selectActiveWeekdayIntegers(state);
      const expected = [1, 2, 3, 4, 5, 6, 7];
 
      expect(actual).toEqual(expected);
    });
 
    test('given all days are deactivated: returns an empty array', () => {
      const actions = [
        toggleMondayIsActive(),
        toggleTuesdayIsActive(),
        toggleWednesdayIsActive(),
        toggleThursdayIsActive(),
        toggleFridayIsActive(),
        toggleSaturdayIsActive(),
        toggleSundayIsActive(),
      ];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectActiveWeekdayIntegers(state);
      const expected = [];
 
      expect(actual).toEqual(expected);
    });
 
    test('given some days are activated (e.g. "monday", "wednesday", "friday"): returns an array of the active days', () => {
      const actions = [
        toggleTuesdayIsActive(),
        toggleThursdayIsActive(),
        toggleSaturdayIsActive(),
        toggleSundayIsActive(),
      ];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectActiveWeekdayIntegers(state);
      const expected = [2, 4, 6];
 
      expect(actual).toEqual(expected);
    });
  });
});

The first test for your selectActiveWeekdayIntegers selector checks that it returns an array with the numbers 1 through 7 when no days are activated.

The second test checks that it returns an empty array when all days are deactivated.

And the third test checks that it returns an array of the numbers representing the activated days when some days are activated.

Watch your tests fail. Now you can implement the selectActiveWeekdayIntegers selector.

src/features/settings/settings-reducer.js
import {
  addIndex,
  all,
  complement,
  converge,
  equals,
  juxt,
  pipe,
  prop,
  reduce,
} from 'ramda';
 
// ... your reducer, action creators, and other selectors
 
/**
 * A selector that maps the activated week days to their integer values for
 * scheduling.
 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#weeklytriggerinput
 *
 * @param {Object} state The redux state.
 * @returns {Array} An array of integers representing the active week days.
 */
export const selectActiveWeekdayIntegers = pipe(
  juxt([
    selectSundayIsActive,
    selectMondayIsActive,
    selectTuesdayIsActive,
    selectWednesdayIsActive,
    selectThursdayIsActive,
    selectFridayIsActive,
    selectSaturdayIsActive,
  ]),
  addIndex(reduce)(
    (accumulator, isActive, index) =>
      isActive ? [...accumulator, index + 1] : accumulator,
    [],
  ),
);

In selectActiveWeekdayIntegers, use juxt to get an array of booleans representing the activated days. Then, use addIndex(reduce) to iterate over the array of booleans. If the day is active, use the index to add the day number (starting at 1) to the accumulator. Otherwise, return the accumulator unchanged.

Watch your tests pass.

Now you can TDD the selectReminderNotificationSchedule selector.

src/features/reminders/reminders-reducer.js
// ... other selectors
 
export const selectReminderNotificationSchedule = () => {};

Implement an empty function for selectReminderNotificationSchedule that does nothing.

Then write the test for this selector.

src/features/reminders/reminders-reducer.test.js
import { rootReducer } from '../../redux/root-reducer';
import {
  remindersPerDayPicked,
  toggleFridayIsActive,
  toggleMondayIsActive,
  toggleThursdayIsActive,
  toggleTuesdayIsActive,
  toggleWednesdayIsActive,
} from '../settings/settings-reducer';
import { createPopulatedReminder } from './reminders-factories';
import {
  reminderAdded,
  reminderDeleted,
  reminderEdited,
  selectReminderById,
  selectReminderNotificationSchedule,
  selectRemindersArray,
} from './reminders-reducer';
 
describe('reminders reducer', () => {
  // ... existing tests
 
  describe('selectReminderNotificationSchedule()', () => {
    test('given initial state, but configured to three reminders per day: returns an array of reminders for each day of the week between 8:00 and 22:00', () => {
      const mockMath = jest.spyOn(Math, 'random').mockReturnValue(1);
 
      const actions = [
        remindersPerDayPicked(3),
        toggleMondayIsActive(),
        toggleTuesdayIsActive(),
        toggleWednesdayIsActive(),
        toggleThursdayIsActive(),
        toggleFridayIsActive(),
      ];
      const state = actions.reduce(rootReducer, rootReducer());
 
      const actual = selectReminderNotificationSchedule(state);
      const expected = [
        {
          content: { title: 'Random Reminder App', body: 'You got this! 💪' },
          trigger: { repeats: true, weekday: 1, hour: 22, minute: 59 },
        },
        {
          content: {
            title: 'Random Reminder App',
            body: 'Learn Redux at a senior-level.',
          },
          trigger: { repeats: true, weekday: 1, hour: 22, minute: 59 },
        },
        {
          content: {
            title: 'Random Reminder App',
            body: 'Give a stranger a compliment 🫂',
          },
          trigger: { repeats: true, weekday: 1, hour: 22, minute: 59 },
        },
        {
          content: { title: 'Random Reminder App', body: 'You got this! 💪' },
          trigger: { repeats: true, weekday: 7, hour: 22, minute: 59 },
        },
        {
          content: {
            title: 'Random Reminder App',
            body: 'Learn Redux at a senior-level.',
          },
          trigger: { repeats: true, weekday: 7, hour: 22, minute: 59 },
        },
        {
          content: {
            title: 'Random Reminder App',
            body: 'Give a stranger a compliment 🫂',
          },
          trigger: { repeats: true, weekday: 7, hour: 22, minute: 59 },
        },
      ];
 
      expect(actual).toEqual(expected);
 
      mockMath.mockRestore();
    });
  });
});

The test checks that the selector returns an array of scheduled notifications for the days of the week, the times and the amount of reminders per day that the user configured.

Watch the test fail.

Then implement the selectReminderNotificationSchedule selector.

src/features/reminders/reminders-reducer.js
import { pipe, prop, values } from 'ramda';
 
import {
  selectActiveWeekdayIntegers,
  selectDayEndTime,
  selectDayStartTime,
  selectRemindersPerDay,
} from '../settings/settings-reducer';
import { createReminder } from './reminders-factories';
import { createNotificationSchedule } from './reminders-helpers';
 
// ... other selectors
 
export const selectReminderNotificationSchedule = state =>
  createNotificationSchedule({
    reminders: selectRemindersArray(state),
    activeDays: selectActiveWeekdayIntegers(state),
    startTime: selectDayStartTime(state),
    endTime: selectDayEndTime(state),
    remindersPerDay: selectRemindersPerDay(state),
  });

Use your createNotificationSchedule helper to create the schedule of notifications. You can grab all of its arguments from the other selectors that you already have.

Watch your test pass.

Now you have everything for the final saga: handleScheduleReminders.

Write the test for this saga.

src/features/reminders/remiders-saga.test.js
import * as Notifications from 'expo-notifications';
import { call } from 'redux-saga/effects';
 
// ... other imports 
 
import {
  // ...
  selectReminderNotificationSchedule,
} from './reminders-reducer';
 
// ... other imports
 
describe('handleScheduleReminders saga', () => {
  it('logs a message to the console', () => {
    const dummyNotificationSchedule = [
      {
        content: { title: 'Random Reminder App', body: 'You got this! 💪' },
        trigger: { repeats: true, weekday: 1, hour: 12, minute: 30 },
      },
      {
        content: { title: 'Random Reminder App', body: 'Learn Redux at a senior-level.' },
        trigger: { repeats: true, weekday: 2, hour: 13, minute: 15 },
      },
    ];
 
    testSaga(handleScheduleReminders)
      .next()
      // This is an expectation. You can read this as `.expect(call(Notifications.cancelAllScheduledNotificationsAsync))`.
      .call(Notifications.cancelAllScheduledNotificationsAsync)
      .next()
      // Same here and for all the other effects - these are expectations.
      .select(selectReminderNotificationSchedule)
      .next(dummyNotificationSchedule)
      .all([
        call(
          Notifications.scheduleNotificationAsync,
          mockNotificationSchedule[0],
        ),
        call(
          Notifications.scheduleNotificationAsync,
          mockNotificationSchedule[1],
        ),
      ])
      .next()
      .isDone();
  });
});
 
// ... test for watchScheduleReminders saga

Import the Notifications module and the call effect, as well as the selectReminderNotificationSchedule selector.

In the test for handleScheduleReminders, create a mock notification schedule and use testSaga to test the saga. First it should call Notifications.cancelAllScheduledNotificationsAsync. Then it should select the reminder notification schedule. Pass the mockNotificationSchedule to the saga using the next method. Afterwards, it should schedule all of the notifications in the schedule. Finally, it should be done.

Watch your test fail.

src/features/reminders/reminders-saga.js
import * as Notifications from 'expo-notifications';
import { pluck } from 'ramda';
import { all, call, debounce, select } from 'redux-saga/effects';
 
// ... other imports
import {
  // ...
  selectReminderNotificationSchedule,
} from './reminders-reducer';
 
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});
 
export function* handleScheduleReminders() {
  yield call(Notifications.cancelAllScheduledNotificationsAsync);
  const notificationSchedule = yield select(selectReminderNotificationSchedule);
  yield all(
    notificationSchedule.map(notification =>
      call(Notifications.scheduleNotificationAsync, notification),
    ),
  );
}

Import Notifications, the all, call and select effects, as well as the selectReminderNotificationSchedule selector.

First, use the setNotificationHandler method to configure how your app responds when it receives a notification while running. It defines handleNotification, an asynchronous function that decides to display an alert to the user (shouldShowAlert: true), but not to play a sound (shouldPlaySound: false) or modify the app's badge number (shouldSetBadge: false).

Then, implement the handleScheduleReminders saga. As you planned earlier, it first cancels all scheduled notifications. Then it selects the reminder notification schedule. Afterwards, it maps over the schedule to schedule each notification.

This is it. Watch your last test pass.

You just made the change easy, and then made the easy change.

Here is the full flow of your app again. While it runs, I want to say:

I love you very much. Thank you so much for watching. If you liked this video, give it a like and subscribe for more advanced JavaScript and React content.

And then go watch the 5-th video of this series in which you'll create a drap-and-drop website editor, also with Redux.

Learn senior fullstack secrets
Subscribe to my newsletter for weekly updates on new videos, articles, and courses. You'll also get exclusive bonus content and discounts.