Getting started with React Native Part 2: Adding functionality

getting-started-react-native-part-2-header.png

This three part series takes you through building a workout tracker app using React Native and Expo. In part two, add functionality to your app: navigate between pages and save data locally using React Native Simple Store.

Introduction

This is part two of a three-part series on getting started with React Native. You can find part one here. In this part, we’ll be adding the functionality to the workout tracking app. Here’s what the final output will look like:

Final app demo

The full source code of the app is available on this Github repo. You can run the demo on your browser or on your Expo client app by scanning the QR code.

Prerequisites

As this is a beginner series, knowledge of the following are not required:

  • React Native
  • React

Knowledge of the following is required:

  • HTML
  • CSS
  • JavaScript – ES5 is required, while familiarity of ES6 features is helpful.
  • Git – cloning repos, switching branches.

The following versions are used in this tutorial:

If you’re reading this at a later time, and you have issues running the code, be sure to check out the following changelogs or release notes. Take note of any breaking changes or deprecations and update the code accordingly. I’ve linked to them above, in case you missed it.

Reading the first part of the series is not required if you already know the basics of layouts and styling React Native. Go through the following steps if you want to follow along:

  • Install Expo and the Expo client app for your iOS or Android device. The client app will be used for running the app.
  • Clone the Github repo:
1git clone https://github.com/anchetaWern/increment.git
2    cd increment
  • Switch to the part1 branch:
1git checkout part1
  • Install all the dependencies:
1npm install

What you’ll be building

In this part of the series, we’ll be picking up from where we left off in the first part. Specifically, we’re going to add the following functionality:

  • Navigation
  • Storage and retrieval of workout data

Installing the dependencies

We’ll be needing two libraries: React Navigation and React Native Simple Store. The former will be used for implementing navigation. While the latter will be used for storing and retrieval of data:

1npm install --save react-navigation react-native-simple-store

Once the two are installed, you can start running the app:

1exp start

Adding navigation code

We’ll start by adding the navigation code for the tabs at the bottom of the screen. This way the user can easily switch between the Routines, Logs and Progress pages.

Start by creating a Root.js file at the root of the project directory. Then import the React Navigation library:

1import React from 'react';
2    import { TabNavigator, StackNavigator } from 'react-navigation';
3    import { MaterialIcons } from '@expo/vector-icons';

From the code above, line 2 imports two kinds of navigator: TabNavigator and StackNavigator. The TabNavigator allows us to easily implement navigation using tabs. StackNavigator is a general-purpose navigator where each screen you navigate to is placed on top of a stack. This means that going back to a previous page means that we’re “popping” the page on top of the stack.

Next, import all of the pages of the app. We’ll be needing it to navigate between each of these pages:

1// Root.js
2    import RoutinesPage from './app/screens/Routines';
3    import LogsPage from './app/screens/Logs';
4    import ProgressPage from './app/screens/Progress';
5
6    import ExercisesPage from './app/screens/Exercises';
7    import CreateExercisePage from './app/screens/CreateExercise';
8    import LogWorkoutPage from './app/screens/LogWorkout';

The TabNavigator renders a component in which navigation functionality is already added. All you have to do is supply the pages in your app and supply the navigationOptions to customize the content for each tab item:

1// Root.js
2    export default TabNavigator(
3      {
4        Logs: {
5          screen: LogsPage
6        },
7        Routines: {
8          screen: RoutinesPage
9        },
10        Progress: {
11          screen: ProgressPage
12        }
13      }
14    );

This will work, but what about the secondary pages which are only available once you press a button in any of these main pages? For this app, the following page hierarchy is used:

  • Logs → Log Workout
  • Routines → Exercises → Create Exercise
  • Progress

For that, you can use the StackNavigator. With it, you can group the related pages together so you can easily navigate between them:

1// Root.js
2    const LogStack = StackNavigator(
3      {
4        Logs: {
5          screen: LogsPage
6        },
7        LogWorkout: {
8          screen: LogWorkoutPage
9        }
10      },
11      {
12        initialRouteName: 'Logs',
13      }
14    );

From the code above, the StackNavigator accepts an object containing the pages that you want to group together. In this case, we’re grouping the Logs page and Log Workout page because pressing any item in the list on the Logs page should navigate to the Log Workout page.

The property names (Logs and LogWorkout) can be anything, as long as it describes the actual pages. The only thing required is the screen property. This refers to the actual page you want to include in the navigation path for this navigator.

If you want to specify a default page, you can pass a second object containing the initialRouteName. This should be the same as one of the property names you used as the first argument (Logs and LogWorkout).

Next is the navigation path for the Routines page. This includes the Routines, Exercises and Create Exercise pages:

1// Root.js
2    const RoutinesStack = StackNavigator(
3      {
4        Routines: {
5          screen: RoutinesPage
6        },
7        Exercises: {
8          screen: ExercisesPage
9        },
10        CreateExercise: {
11          screen: CreateExercisePage
12        }
13      },
14      {
15        initialRouteName: 'Routines'
16      }
17    );

I’ll leave the implementation of navigation code for the Progress page to you. The code is the same as the previous two. The only difference is that there’s only one page to be supplied in the StackNavigator.

Next, declare the icons to be used for each of the main pages of the app:

1const icons = {
2      'Logs': 'event-note',
3      'Routines': 'edit',
4      'Progress': 'camera-alt'
5    };

Once that’s done, you can export the TabNavigator like a normal component. Don’t forget to replace the code with the StackNavigator instances that we’ve just created:

1// Root.js
2    export default TabNavigator(
3      {
4        Logs: {
5          screen: LogStack
6        },
7        Routines: {
8          screen: RoutinesStack
9        },
10        Progress: {
11          screen: ProgressStack
12        }
13      },
14      // next: add code for navigation options
15    );

You can then add the code for customizing the look and behavior of the TabNavigator. By default, it will only show the title of the pages for each of the tab items. To show an icon, supply a tabBarIcon property. This allows us to decide which icon we want to use based on the current page:

1{
2      navigationOptions: ({ navigation }) => ({ // the navigation object that's automatically passed via props when using a navigator component
3        tabBarIcon: ({ focused, tintColor }) => { 
4          const { routeName } = navigation.state; // name of the current page
5          let iconName = icons[routeName];
6          let color = (focused) ? '#fff' : '#929292'; // if this page is the one currently viewed, use white as the icon color to indicate that it's active
7
8          return <MaterialIcons name={iconName} size={35} color={color} />;
9        },
10      }),
11      tabBarPosition: 'bottom', // where to put the tab bar (top or bottom of the screen)
12      animationEnabled: true, // show an animation when navigating between pages. the default is a sliding animation
13      tabBarOptions: {
14        showIcon: true, // show icons you've rendered in the the tabBarIcon
15        showLabel: false, // don't show labels in the tabs
16        style: {
17          backgroundColor: '#333' // the background color of the tab bar
18        }
19      }
20    },
21    // next: add code for specifying the initial page

Specify the initial page:

1{
2      initialRouteName: 'Routines'
3    }

You can learn more about how to customize the TabNavigator by checking the reference.

Next, update the App.js file to use the Root component:

1import React from 'react';
2    import { StyleSheet, View } from 'react-native';
3
4    import Root from './Root';
5
6    export default class App extends React.Component {
7
8      render() {
9        return (
10          <View style={styles.container}>
11            <Root />
12          </View>
13        );
14      }
15
16    }
17
18    const styles = StyleSheet.create({
19      container: {
20        flex: 1,
21        backgroundColor: '#fff'
22      }
23    });

The change above would cut the need for the Screen component that we added on the first part. So you can now delete that component.

Adding functionality

Now that we’ve set up the tab navigation, we can proceed with adding the functionality for each of the pages. So instead of using hard-coded data for each of them, we will now be using a local storage where we can save and retrieve data. Aside from that, we’ll also add the code for handling stack navigation in each of the pages.

Routines page

First, we’ll add the navigation code for the routines page. Not much will change in the code for this page because we’ll still be using the hard-coded data from the app/data/routines.js file.

On app/screens/Routines.js, start by importing the things we need:

1import React from 'react';
2    import { FlatList, TouchableHighlight, Text } from 'react-native';
3
4    import routines_data from '../data/routines';
5
6    import styles from '../lib/styles';

Inside the component class, customize how the navigation will look like by declaring a static variable called navigationOptions. The variable name should be written as-is because this is expected by the StackNavigator:

1// app/screens/Routines.js
2    static navigationOptions = ({navigation}) => ({
3      headerTitle: 'Routines',
4      headerStyle: {
5        backgroundColor: '#333'
6      },
7      headerTitleStyle: {
8        color: '#FFF'
9      }
10    });

In the code above, we’re customizing the title and the style of the header. The navigator takes over the rendering of the header because it needs to add things like the back button whenever you push a page on top of the stack. The headerTitle is used as the label for the header when the page is viewed. It also doubles as the label for the back button when going back to this page. Though it defaults to using Back as the label if the label you’ve set is too long.

Next, we’ll need to write our own renderItem() function instead of relying on the generic renderItem() function found on the app/lib/general.js file. This allows us to execute a specific function when any of the items are pressed:

1// app/screens/Routines.js
2    renderItem = ({item}) => {
3      const { navigate } = this.props.navigation;
4      return (
5        <TouchableHighlight key={item.key} underlayColor="#ccc" onPress={() => {
6          navigate('Exercises', {
7            'key': item.key,
8            'name': item.name
9          });
10        }} style={styles.list_item}>
11          <Text key={item.key}>{item.name}</Text>
12        </TouchableHighlight>
13      );
14    }

Using the StackNavigator passes a navigation props to all the pages you’ve used it on. In our case, it’s available on all the files on the app/screens folder. The navigation props include a navigate() function which we can use to navigate between pages. In the code above, we’re using it to navigate to the Exercises page. We’re also passing in two navigation parameters: key and name:

  • key – the unique ID is given to the exercise routine.
  • name – the name of the exercise routine.

You’ll see how to access these two later in the Exercises page.

You might also notice that we’ve used the ES6 arrow function syntax for creating the renderItem() function instead of declaring it like so:

1renderItem() {
2      // function body here  
3    }

The reason for this is that we’ll need to bind the method in the constructor if we want the context of this to be this component class when we use this inside the renderItem() method:

1constructor(props) {
2      super(props);
3      this.renderItem = this.renderItem.bind(this); 
4    }

Using the arrow function syntax helps us to avoid the additional work above, and our code will be much cleaner.

Exercises page

The Exercise page will use the React Native Simple Store library to get the exercises that are stored locally. We’re also using a new AlertBox component for showing a message to the user.

The renderItem() function from the app/lib/general.js file is still used because we don’t really want to execute any custom function when the user taps on a list item:

1// app/screens/Exercises.js
2    import React from 'react';
3    import { View, Text, FlatList } from 'react-native';
4    import store from 'react-native-simple-store';
5
6    import IconButton from '../components/IconButton';
7    import AlertBox from '../components/AlertBox';
8    import list_styles from '../components/List/styles';
9
10    import { renderItem } from '../lib/general';

The navigationOptions for this page looks a bit different from the last one. This is because we want to extract the navigation params into an object. That way, we can refer to that object instead of doing something like navigation.state.params.stateParam1 every time we want to use any of the navigation parameters. We can’t really do that if we used the previous way of declaring the navigationOptions:

1static navigationOptions = ({ navigation }) => {
2      const { params } = navigation.state; // extract the navigation parameters to the params object
3      // return the data needed by navigationOptions
4      return {
5        headerTitle: 'Exercises',
6        headerRight: (
7          <IconButton size={25} color="#FFF" onPress={() => {
8            navigation.navigate('CreateExercise', {
9              'key': params.key,
10              'name': params.name,
11              'updateExercises': params.updateExercises
12            });
13          }} />
14        ),
15        headerStyle: {
16          backgroundColor: '#333'
17        },
18        headerTitleStyle: {
19          color: '#FFF'
20        }
21      }
22    }

Most of the code above is the same as the previous one. The only difference is in the object passed as the navigation params. From the code in the app/screens/Routines.js file earlier, we have passed the key and name as the navigation params. That is what we’re accessing. So we’re basically passing the same thing to the next page which is the Create Exercise page.

But what about the updateExercises? We haven’t really passed it on the previous page, so where did it come from? Well, I’ll explain it to you later. For now, know that we’re creating this parameter on the same file:

1navigation.navigate('CreateExercise', {
2      'key': params.key,
3      'name': params.name,
4      'updateExercises': params.updateExercises
5    });

Next, you need to update the IconButton component (app/components/IconButton/IconButton.js) so it passes the onPress prop:

1<TouchableHighlight style={styles.icon_button} underlayColor="#ccc" onPress={props.onPress}>
2     ...
3    </TouchableHighlight>

Then go back to app/screens/Exercises.js and initialize the state:

1state = {
2      exercises_data: []
3    }

Since this is the first time that we’re using the state, I’ll explain to you briefly what state is, and how it’s used in React Native. The “state” is used to store data that can be changed over time. This change can be a result of a user interaction (e.g. when a user taps on a button), or the execution of another code in the app. The type of data usually stored in the state are the ones that affect what’s being rendered in the UI. For example, if you have a button which changes its label every time it’s pressed. You want to put the label for that button in the state. That way you can easily update it every time the button is pressed.

So, going back to the code earlier, we’re initializing the exercises_data to an empty array. Because later on, we’ll be updating it with an array containing the exercises data:

1state = {
2      exercises_data: []
3    }

Next, declare the function that will update the exercises_data. This accepts an array of exercises which will then be used to update the state:

1updateExercises = (exercises) => {
2      this.setState({
3        exercises_data: exercises
4      });
5    }

Once the component is mounted:

  • Use the React Native Simple Store library to fetch the exercises data from the local storage.
  • Update the state with the fetched data.

The library acts as a wrapper for the AsyncStorage API in React Native. We’re using it to make it easy to store and retrieve arrays. Because with AsyncStorage you can only store string values. Later on, you’ll see how the array of exercise data is stored:

1componentDidMount() {
2
3      store.get('exercises') // 'exercises' is the key
4        .then((response) => {
5          if(response){ // 'response' is the array of exercises
6            this.setState({
7              exercises_data: response
8            });
9          }
10        });
11
12      // next: add code for setting additional navigation params
13    }

Before we proceed with the next bit of code, I’d like to give a brief overview of component lifecycle methods on React. One such method is the componentDidMount which we just used. This method is called once the component (and all of its sub-components) is rendered on the screen. There are also lifecycle methods which are executed right before something happens. Those methods are indicated by the will prefix. While anything that has a did prefix is executed after something happens.

But what exactly are the things that happen? As the name “component lifecycle” suggests, these are the things that happen during the lifetime of a component. Here are a few examples:

  • componentWillReceiveProps – if the component relies on props passed from its parent, this function is called right before the component is re-rendered when the props passed by its parent is changed.
  • componentDidMount – called only once during the lifecycle of a component when it has been fully rendered on the screen along with its sub-components.
  • componentDidUpdate – called everytime the component and all of its sub-components is fully rendered. We already know that a component is re-rendered everytime a state value which it depends on is updated. So this method is called right after every time that happens.

You can learn more about the best practices when using lifecycle methods in this article: Understanding React — Component Lifecycle.

Next, set an additional navigation params called updateExercises. This uses the updateExercises() function from earlier:

1this.props.navigation.setParams({
2      'updateExercises': this.updateExercises
3    });

So why exactly are we setting this as an additional navigation parameter? And then once again passing it as a navigation parameter for the Create Exercise page? Well, the main purpose of passing this method as a navigation parameter is because we want the Create Exercise page to be able to call it. This way, we can update the state of the Exercises page inside the Create Exercises page.

Which begs the question: Why can’t we just pass the function directly like so:

1navigation.navigate('CreateExercise', {
2      // ...
3      'updateExercises': this.updateExercises
4    });

This won’t work because the context of this in this.updateExercises is not the component class. this actually refers to the navigationOptions so we can’t access it like that.

Note that we won’t really need to do things like this if we’re using state management libraries such as Redux or MobX. Using those libraries, you can have a global state which can be accessed from any component. As opposed to the built-in React state which can only be managed within the component where it is declared. Since this is a beginner tutorial, we won’t use any of those libraries.

Next is the render() function. Here we’re:

  • Filtering out the exercises which belong to the same routine that we selected.
  • The filtered data is then used as the data source for the FlatList.
  • The renderItem() function from app/lib/general.js is used to render each list item.

All the renderItem() function does is render a TouchableHighlight component, which shows the name property of the current object. We’ve already written the code for that in the previous tutorial, and the specific function hasn’t been changed. So you can simply check the code if you want a refresher:

1render() {
2      const { params } = this.props.navigation.state;
3      let routine = params.key;
4
5      let exercises = this.state.exercises_data.filter((item) => {
6        return item.routine == routine;
7      });
8
9      return (
10        <View>
11          <Text style={list_styles.list_item_header}>{params.name}</Text>
12          <FlatList data={exercises} renderItem={renderItem} keyExtractor={(item, index) => item.id} />
13          {
14            exercises.length == 0 &&
15            <AlertBox type="info" text="You haven't added any exercises for this routine yet." />
16          }
17        </View>
18      );
19    }

Look at the code above. Note that we’ve specified an additional keyExtractor prop to the FlatList. This allows us to specify a function to be used for extracting the key for each list item. As you’ve learned in the previous tutorial, a unique key is needed for each item in the list. In this case, we’re using the id as the unique key. This is added as one of the properties of the exercise object when an exercise is created. You’ll see how it’s being generated later.

If there are no exercises for a specific routine, an AlertBox is shown instead. This is how we do conditional rendering in React Native. Just like variables, you can embed JavaScript expressions within the markup itself. Since we’ve used the && condition, exercises.length should have a value of 0 so that the right side of the expression is evaluated:

1{
2      exercises.length == 0 &&
3      <AlertBox type="info" text="You haven't added any exercises for this routine yet." />
4    }

I’ll leave the implementation of the AlertBox component to you since it follows the same pattern as the components we created in the previous tutorial. It should accept the type and text as props. The background color of the box depends on the type specified.

Create exercise page

The Create Exercise page is where we store exercise data in the local storage so we’ll need the uniqid() function from the app/lib/general.js file. We’ll add this function later, but for now, know that it generates a unique ID for the items that we’re going to save:

1// app/screens/CreateExercise.js
2    import React from 'react';
3    import { View, Text, TextInput, Picker, StyleSheet, Button, Alert } from 'react-native';
4    import store from 'react-native-simple-store';
5
6    import routines_data from '../data/routines';
7
8    import { renderPickerItems, uniqid } from '../lib/general';

Next, initialize the state with the default data for an exercise:

1state = {
2      name: '', // the name of the exercise
3      routine: this.props.navigation.state.params.key, // the muscle group being targetted by the exercise. It defaults to the routine that was selected from the exercises page earlier
4      sets: '3', // the default number of sets for each exercise
5      exercises: [] // the array of exercises
6    };

Once the component is mounted, we do the same thing that we did on the Exercises page earlier. And that is to update the state with the exercise data coming from the local storage. We need this because later on, we’re going to update the state every time a new exercise is added. Doing this helps keep the two pages in sync:

1componentDidMount() {
2      store.get('exercises')
3        .then((response) => {
4          if(response){
5            this.setState({
6              'exercises': response
7            });
8          }
9        });
10    }

Next, we need to update the code for all the input fields so that they use the values stored in the state:

  • First is the exercise name. Add an onChangeText prop and use an arrow function to update the name value in the state.
  • Set a value prop with its value being the name in the state.

That is how we keep the user input and its corresponding state value in sync:

1<Text style={styles.label}>Name</Text>
2    <TextInput
3      onChangeText={(name) => this.setState({name})}
4      value={this.state.name}
5    />

As for the Picker, you need to add the selectedValue and onValueChange props:

  • selectedValue is the equivalent of value for a TextInput component.
  • onValueChange is the equivalent of onChangeText. onValueChange is only triggered when the picker value changes.

That’s why we’ve set a default value for the routine: so that it will still have a value in case the user doesn’t change their input. An additional argument itemIndex is passed to it in case you want to know the index of the selected item:

1<Picker
2      selectedValue={this.state.routine}
3      onValueChange={(itemValue, itemIndex) => this.setState({routine: itemValue})}
4      >
5      {renderPickerItems(routines_data)}
6    </Picker>

I’ll leave the implementation for updating the number of sets in the state to you. It’s pretty much the same as how we did it for the exercise name.

Next, create the saveExercise() function. This will save the new exercise locally, and also update the state in the Exercises page. Here we’re using the push() method from the simple store library to push a new exercise into the exercises array. If this is the first exercise being added, it will simply initialize the array and push the object as its first item:

1saveExercise = () => {
2
3      let id = uniqid(); // generate a unique id
4      let new_exercise = {
5        'id': id,
6        'name': this.state.name,
7        'routine': this.state.routine,
8        'sets': this.state.sets
9      };
10
11      store.push('exercises', new_exercise);
12
13      Alert.alert(
14        'Saved',
15        'The exercise was successfully saved!',
16      );
17
18      // next: add code for updating the state with the new exercises
19    }

Next, create a new array based on the current exercises data:

1let exercises = [...this.state.exercises]; // create new array from the exercises data
2    exercises.push(new_exercise);
3
4    this.setState({
5      name: '',
6      sets: '3',
7      exercises: exercises
8    });
9
10    // next: add code for updating the exercises on the exercises page

Looking at the code above, you might be asking why we need to create a new array based on the one currently in the state. Why can’t we just push the new object directly like so:

1let exercises = this.state.exercises;
2    exercises.push(new_exercise);
3    this.setState({
4      exercises: exercises
5    });

The problem with this is that objects and arrays in JavaScript are passed by reference. So the code below doesn’t actually create a new array called exercises. It’s only creating a new reference for this.state.exercises:

1let exercises = this.state.exercises;

So doing exercises.push() is basically equivalent to this.state.exercises.push(). This is bad practice because you’re bypassing React’s state management system. Making changes to the state should only be done through the setState() function.

To prevent modifying the state directly, I’ve used the ES6 spread operator to create a new array based on the value of this.state.exercises:

1let exercises = [...this.state.exercises];

Next, call the updateExercises() function that we passed from Exercises page earlier. This will effectively update the state of the Exercises page with the new exercises data:

1this.props.navigation.state.params.updateExercises(exercises);

Logs page

The Logs page needs to be updated so that it uses the workout data from the local storage. We’re using it instead of the hard-coded data that we’ve added in the previous tutorial. Aside from the components and packages that we’ve already used previously, we also need to import the lastWeeksDates() and getShortMonth() function from the app/lib/general.js file. The lastWeeksDates() function returns an array containing the current date along with the dates for the last six days. While the getShortMonth() returns the short version of the full month name (e.g. January becomes Jan). We’ll create those two functions later:

1// app/screens/Logs.js
2    import React from 'react';
3    import { View, Text, TouchableHighlight, FlatList, StyleSheet } from 'react-native';
4    import store from 'react-native-simple-store';
5
6    import IconButton from '../components/IconButton';
7    import AlertBox from '../components/AlertBox';
8
9    import list_styles from '../components/List/styles';
10
11    import { getDate, lastWeeksDates, uniqid, getShortMonth } from '../lib/general';

For the navigationOptions, we need to pass the current date as a navigation param for the Log Workout page. The button in the header allows the user to log their workout for the current day. That’s why we need to pass on the current date. The date will serve as a basis on which specific log should be loaded by the page:

1static navigationOptions = ({navigation}) => ({
2      headerTitle: 'Logs',
3      headerRight: (
4        <IconButton size={25} color="#FFF" onPress={() => {
5          navigation.navigate('LogWorkout', {
6            date: getDate()
7          });
8        }} />
9      ),
10      headerStyle: {
11        backgroundColor: '#333'
12      },
13      headerTitleStyle: {
14        color: '#FFF'
15      }
16    });

Next, initialize the logs_data:

1state = {
2      logs_data: []
3    };

Once the component is mounted, we want to fetch the workout data from the last 7 days (including today). The workout data is stored by using the current date (e.g. "4/10/2018``") as the primary key component. This means that we’re not storing all of the workout data in a single array just like the exercises.

Start by getting the dates for the last seven days:

1let dates = lastWeeksDates(); // ["4/10/2018", "4/9/2018", "4/8/2018", ...]

Create a new array off of those by adding a suffix to each item. The suffix allows us to generate the same key that was used when the data was saved:

1let keys = dates.map((date) => {
2      return date + '_exercises';
3    });

We then use the array of keys as the argument for the get() function. Previously, we’ve only supplied a string value since we only needed to get the data from a single store. But now we need to fetch the data from multiple stores, so we supply an array instead. Once the response comes back, we already have all the data that we need. This saves us time because we no longer need to call store.get() multiple times and then use something like Promise.all() to get the data all at once:

1let logs_data = [];
2    store.get(keys)
3      .then((response) => {
4        // next: do something with the response
5      });

Next, loop through each item. Each item contains the same data that we get if we were to call store.get() for each date. So each item contains an array of objects which represents the exercise data. All we need to do is to fill the logs_data array with objects containing the same properties as the ones in app/data/logs.js file. The only addition is the date because we need it for navigating to the Log Workout page:

1response.forEach((workout_session, index) => {
2      let date = dates[index];
3
4      if(workout_session){ // check if there's a workout data 
5        let exercises = [];
6        workout_session.forEach((item) => {
7          exercises.push(item.exercise_name);
8        });
9
10        let d = new Date(date);
11
12        let month = getShortMonth(d.getMonth()); // format the date to a short month (e.g. Apr, Jun)
13        let day = d.getDate(); // get the actual day
14
15        logs_data.push({
16          key: uniqid(),
17          date: date, // for navigating to log workout page
18          month: month,
19          day: day,
20          exercises: exercises.splice(0, 3).join(', ') + '...' // get only the first three exercises
21        });
22
23      }
24    });

Render the list using the logs_data as the data source:

1<FlatList data={this.state.logs_data} renderItem={this.renderItem} />

Render the AlertBox for handling the empty state:

1{
2      this.state.logs_data.length == 0 &&
3      <AlertBox type="info" text="You haven't logged any sessions yet." />
4    }

Convert the renderItem() function to an arrow function. No changes need to be made in the function body:

1renderItem = ({item}) => {
2      // code from the previous tutorial
3    }

When any of the log items is pressed, navigate to the Log Workout page while passing the date for that specific log as a navigation param:

1<TouchableHighlight underlayColor="#ccc" onPress={() => {
2      this.props.navigation.navigate('LogWorkout', {
3        date: item.date
4      });
5    }}>
6      ...
7    </TouchableHighlight>

Log Workout page

The Log Workout page is the main meat of the app. Nothing really new with the things we need to import:

1// app/screens/LogWorkout.js
2    import React from 'react';
3    import { View, Text, ScrollView, FlatList, Modal, Picker, TextInput, Button, StyleSheet, Alert } from 'react-native';
4    import store from 'react-native-simple-store';
5
6    import List from '../components/List';
7
8    import IconButton from '../components/IconButton';
9    import SetContainer from '../components/SetContainer';
10    import AlertBox from '../components/AlertBox';
11
12    import { renderPickerItems, uniqid } from '../lib/general';

In the navigationOptions, call the showAddExerciseModal() function. As the name suggests, this is used for showing the modal for adding a new exercise to the current workout session. We will pass this function later once the component is mounted. The main reason why we need to do this instead of directly calling this.showAddExerciseModal() is because the context of this isn’t really the component class itself. This means that we don’t have access to things like this.state inside the navigationOptions:

1static navigationOptions = ({navigation}) => {
2      const { params } = navigation.state;
3
4      return {
5        headerTitle: 'Log Workout',
6        headerRight: (
7          <IconButton size={25} color="#FFF" onPress={() => params.showAddExerciseModal() } />
8        ),
9        headerStyle: {
10          backgroundColor: '#333'
11        },
12        headerTitleStyle: {
13          color: '#FFF'
14        }
15      };
16
17    };

Next, initialize the state. This includes the visibility of the two modals: Add Exercise and Add Set, the workout data, and the value for the input fields in each of the modals:

1state = {
2      add_exercise_visible: false, // hide the add exercise modal by default
3      add_set_visible: false, // hide the add set modal by default
4      workouts_data: [],
5      exercises_data: [],
6      sets_data: [],
7      selected_exercise: '',
8      weight: '',
9      current_set_exercise: ''
10    };

Once the component is mounted, we get all the data that we need from the local storage. This includes:

  • exercises – serves as the data source for the picker when adding a new exercise to the workout data.
  • date _exercises – exercises added to the workout data.
  • date _sets – sets added to the workout data.
1componentDidMount() {
2
3      let date = this.props.navigation.state.params.date; // access the date passed from the Logs page earlier
4
5      let keys = ['exercises', date + '_exercises', date + '_sets']; // array of store keys whose data we need to fetch
6
7      store.get(keys)
8        .then((response) => {
9          // next: add code for updating the state with the fetched data
10        });
11
12    }

Once a response comes back, update the state with the data. Remember that because we supplied an array of keys as an argument to the store.get() function, we will also get an array containing the response for each key. So the first key’s response would be stored at index 0 of the response and so on.

The selected_exercise is the exercise selected by the user when they add a new set. It defaults to the first exercise added in the workout session, but it doesn’t really matter which value is selected by default. This is because its value will change as soon as the user taps on the add set button for a specific exercise. You’ll see this in action later on:

1.then((response) => {
2
3      let exercises_data = (response[0]) ? response[0] : []; // the data for the picker on the add exercise modal
4      let selected_exercise = (response[0]) ? response[0][0].id : '';
5      let workouts_data = (response[1]) ? response[1] : []; // the exercises data for the specific workout session
6      let sets_data = (response[2]) ? response[2] : []; // the data for the sets below each exercise
7
8      this.setState({
9        exercises_data,
10        selected_exercise,
11        workouts_data,
12        sets_data
13      });
14
15    });
16
17    // next: add code for setting additional navigation param

Next, add the showAddExerciseModal as a navigation param:

1this.props.navigation.setParams({
2      showAddExerciseModal: this.showAddExerciseModal
3    });

Here’s the function, it simply updates the state so that the Add Exercise modal becomes visible:

1showAddExerciseModal = () => {
2      this.setState({
3        add_exercise_visible: true
4      });
5    }

Don’t forget to update the visible prop for each modal to use the values in the state. This way, their visiblity can be controlled by updating the state. The same is true with onRequestClose. As mentioned in the previous tutorial, this only gets executed when the user presses on the hardware back button on Android. If that happens, the modal should be closed so the user can see the previous screen:

1<Modal
2      animationType="slide"
3      visible={this.state.add_exercise_visible}
4      onRequestClose={() => {
5        this.setState({
6          add_exercise_visible: false
7        });
8      }}>
9      ...
10    </Modal>
11
12    ...
13
14    <Modal
15      animationType="slide"
16      visible={this.state.add_set_visible}
17      onRequestClose={() => {
18        this.setState({
19          add_set_visible: false
20        });
21      }}>
22      ...
23    </Modal>

The close button for each modal would simply set the corresponding state to false:

1this.setState({
2      add_exercise_visible: false
3    });

I’ll leave it to you to do the same for when the close button for the Add Set modal is pressed.

In the markup for the Add Exercise modal, add the selectedValue and onValueChange props. Update it so that it updates the value for the selected_exercise:

1<Picker
2      selectedValue={this.state.selected_exercise}
3      onValueChange={(itemValue, itemIndex) => this.setState({selected_exercise: itemValue}) }
4      >
5      {renderPickerItems(this.state.exercises_data)}
6    </Picker>

Still looking at the code above, the argument we supplied to the renderPickerItems() function is the array containing all the exercises that the user has added via the Create Exercise page. This function is defined in the app/lib/general.js file. You need to update it so it contains the following:

1function renderPickerItems(data) {
2      return data.map((item) => {
3        let val = item.name.toLowerCase();
4        let id = (item.key) ? item.key : item.id;
5        return (
6          <Picker.Item key={id} label={item.name} value={id} />
7        );
8      });
9    }

As you can see from the code above, it uses the item key as the value for both the key and value prop for each picker item (if it’s available). If it’s not then it uses the id instead.

If you go back to the code for the saveExercise() function in the app/screens/CreateExercise.js file. You’ll see that we’re only saving an id but not a key:

1saveExercise = () => {
2
3        let id = uniqid();
4        let new_exercise = {
5          'id': id,
6          'name': this.state.name,
7          'routine': this.state.routine,
8          'sets': this.state.sets
9        };
10      ...
11    }

This means that the renderPickerItems() function will use the id. And this id is a unique ID generated to represent the exercise data. So every time the value for the exercise Picker changes, the itemValue being returned is actually the exercise id and not the exercise name. This knowledge will come into play later on the function for adding an exercise.

Going back to the app/screens/LogWorkout.js file, you can now update the onPress handler for the add exercise button. We’ll add this function later:

1<Button
2      onPress={this.addExercise}
3    />

Once the button for adding an exercise is pressed, the addExercise() function is executed. Inside, find the actual exercise based on the selected exercise ID:

1addExercise = () => {
2      let id = uniqid();
3      let date = this.props.navigation.state.params.date;
4
5      let exercises_data = this.state.exercises_data;
6      // get the exercise data based on the currently selected exercise.
7      let exercise = exercises_data.find((item) => {
8        return item.id == this.state.selected_exercise;
9      });
10
11      // next: create an object containing the exercise data
12    }

Create an object containing the exercise data:

1let new_exercise = {
2      'key': id,
3      'exercise_id': this.state.selected_exercise,
4      'exercise_name': exercise.name,
5      'exercise_sets': exercise.sets
6    };
7
8    // next: add code for updating local storage and state with the new exercise

Add the new exercise into the workouts data, and update the state:

1let workouts_data = [...this.state.workouts_data]; // create a new array based on the workouts_data
2    workouts_data.push(new_exercise);
3
4    store.push(date + '_exercises', new_exercise);
5
6    this.setState({
7      workouts_data: workouts_data
8    });
9
10    Alert.alert(
11      'Saved',
12      'The exercise was successfully added!',
13    );

Next, inside the modal for adding a set, add the code for updating the weight:

1<TextInput
2      onChangeText={(weight) => this.setState({weight})}
3      value={this.state.weight}
4    />

Also, update the onPress handler:

1<Button
2      onPress={this.addSet}
3    />

The addSet() function adds a new set for a specific exercise that was added to the workout data:

1addSet = () => {
2      let id = uniqid();
3      let date = this.props.navigation.state.params.date; // uses the date passed from the logs page earlier
4      let weight = this.state.weight;
5
6      let sets_data = [...this.state.sets_data]; // create a new array based on the sets_data
7      let new_set = {
8        'key': id,
9        'weight': weight,
10        'exercise_id': this.state.current_set_exercise, // the selected exercise ID
11        'reps': 5 // the baseline number of reps
12      };
13
14      store.push(date + '_sets', new_set);
15
16      this.setState({
17        add_set_visible: false, // immediately hide the add set modal upon adding the set
18        sets_data: [...sets_data, new_set]
19      });
20    }

Next, update the FlatList to use the workout data. You also need to add the extraData prop and pass this.state.sets_data as the value. This allows us to re-render the list when the sets_data changes. Because by default, the only change that would cause a re-render is a change in the data source which is this.state.workouts_data:

1<FlatList data={this.state.workouts_data} extraData={this.state.sets_data} renderItem={this.renderItem} />

Don’t forget to show an AlertBox if there’s no workout data:

1{
2      this.state.workouts_data.length == 0 &&
3      <AlertBox type="info" text="No workouts for this session yet." />
4    }

As for the renderItem() function, you only need to update the data being rendered, and add an onPress handler when the Add Set button is pressed:

1renderItem = ({item}) => {
2      return (
3        <View key={item.key}>
4          <View style={styles.list_item_header}>
5            <Text style={styles.list_item_header_text}>{item.exercise_name} ({item.exercise_sets})</Text>
6            <IconButton icon="add" size={20} color="#333" onPress={() => this.showAddSetModal(item.exercise_id)} />
7          </View>
8          {this.renderSets(item.exercise_id, item.key)}
9        </View>
10      );
11    }

The showAddSetModal() function accepts the exercise ID. This ID is set as the current_set_exercise, which is used for keeping track of which specific exercise the user is adding a set to:

1showAddSetModal(exercise_id) {
2      this.setState({
3        current_set_exercise: exercise_id, // update exercise ID used when adding a set to an exercise
4        add_set_visible: true // make the add set modal visible
5      });
6    }

As for the renderSets() function, we need to filter the sets based on the current exercise. The exercise_id is already supplied as an argument everytime the function is called so we can use that for filtering:

1renderSets(exercise_id, key) {
2      let sets_data = this.state.sets_data;
3      let sets = sets_data.filter((item) => {
4        return item.exercise_id == exercise_id;
5      });
6      // next: add code for generating list key
7    }

After that, we also need to generate the list key. This is required for lists within a list. Note that the parent list is the list of exercises added in a workout. And within each item is another list which is composed of the sets:

1let l_key = exercise_id + ":" + key + ":" + uniqid();

Next, check if there are any sets added. We don’t want to render anything if a set hasn’t been added:

1if(sets.length){
2      ...
3    }

Update the List to use the filtered sets data as well as the listKey. The value for the renderItem prop remains the same:

1<List data={item.sets} listKey={l_key} renderItem={...}>

You also need to update the List component (app/components/List/List.js). Update it so it doesn’t use the columnWrapperStyle if there’s only one item in the array supplied as its data source. columnWrapperStyle is only meant for lists with more than one item. You should also supply the listKey prop so it can use the value you supplied. Lists with only one item doesn’t need it:

1const List = (props) => {
2
3      if(props.data.length > 1){
4        return (
5          <View style={styles.list}>
6            <FlatList
7              key={props.data.length}
8              listKey={props.listKey}
9              numColumns={props.data.length}
10              columnWrapperStyle={styles.wrapper}
11              data={props.data}
12              renderItem={props.renderItem}
13            />
14          </View>
15        );
16      }
17
18      return (
19        <View style={styles.list}>
20          <FlatList
21            key={props.data.length}
22            numColumns={props.data.length}
23            data={props.data}
24            renderItem={props.renderItem}
25          />
26        </View>
27      );
28
29    }

Next, add an onPress handler for the SetContainer:

1<SetContainer onPress={() => this.incrementSet(item)} />

Don’t forget to update app/components/SetContainer/SetContainer.js to use the onPress prop as the value for the onPress prop in the corresponding TouchableHighlight component:

1<TouchableHighlight style={styles.set_container} onPress={props.onPress} underlayColor="#eee">
2    ...
3    </TouchableHighlight>

Lastly, the incrementSet() function increments the reps for a specific set data by one. As you have seen in the function for adding sets earlier, each set data has a key property. We’re using this key to find the actual set that we need to update. Specifically:

  • Get its index and create a new object based on that.
  • Increment the current reps value by 1.
  • Update the state and local storage with the new data.
1incrementSet = (item) => {
2      // find the set based on the key
3      let sets_data = [...this.state.sets_data];
4      let index = sets_data.findIndex((itm) => {
5        return itm.key == item.key;
6      });
7
8      let reps = item.reps;
9      sets_data[index] = {...sets_data[index], 'reps': reps + 1};
10      // update the state
11      this.setState({
12        sets_data: sets_data
13      });
14      // update local storage
15      let date = this.props.navigation.state.params.date;
16      store.save(date + '_sets', sets_data);
17    }

Progress page

I’ll leave the implementation of navigation for the Progress page (app/screens/Progress.js) to you. It’s very similar to the one in the Logs page. Aside from that, nothing else really needs changing because we’ll add the functionality for the Progress page in the final part of this series.

General functions

Here is the code for the functions that we’ve been using throughout the app. First is the uniqid() function. As you’ve seen earlier, we’re mainly using it to generate a unique key for each item that we save in the local storage:

1function uniqid() {
2      return Math.random().toString(36).substr(2, 9);
3    }

The getLocalDateTime() function formats the current date and time to something that we could present to the user (e.g. 3/23/2018, 18:23). Both the getDate() and lastWeeksDates() function relies on it to format the date. Although the date object created with new Date() has a toLocaleString() function which does the same thing, we can’t really rely on that because it doesn’t work on Android:

1function getLocalDateTime(date) {
2
3      let hours = date.getHours();
4      if (hours < 10) hours = '0' + hours;
5
6      let minutes = date.getMinutes();
7      if (minutes < 10) minutes = '0' + minutes;
8
9      let month = date.getMonth() + 1; // add 1 because month numbers starts at zero
10      return month + '/' + date.getDate() + '/' +
11             date.getFullYear() + ', ' + hours + ':' + minutes;
12    }

The getShortMonth() function returns the short version of the month name based on the month number. The month numbers come from the getMonth() function of the Date object. The month numbers start at zero, that’s why we can directly use it as an index to the months array:

1function getShortMonth(month_number) {
2      let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul","Aug", "Sep", "Oct", "Nov", "Dec"];
3      return months[month_number];
4    }

The getDate() function returns the current date with the format MM/DD/YYYY:

1function getDate() {
2      let datetime = getLocalDateTime(new Date());
3      let date = datetime.substr(0, datetime.lastIndexOf(','));
4      return date;
5    }

lastWeeksDates() returns an array containing the dates for the past seven days including the current date. The dates use the same format as the one returned by the getDate() function above:

1function lastWeeksDates () {
2      let dates = [];
3      for(let i = 0; i < 7; i++){
4          let d = new Date();
5          d.setDate(d.getDate() - i);
6          let datetime = getLocalDateTime(d);
7          let formatted_date = datetime.substr(0, datetime.lastIndexOf(','));
8          dates.push(formatted_date);
9      }
10
11      return dates;
12    }

Lastly, don’t forget to export those three functions along with the one’s that we’ve previously added:

1export { renderItem, renderPickerItems, uniqid, getDate, lastWeeksDates, getShortMonth };

Further reading

Here is some recommended reading for mastering the basic React concepts:

Conclusion

That’s it! In this part, you’ve learned how to add functionality to a React Native app using JavaScript. Specifically, you’ve learned how to navigate between the pages of the app using the React Navigation library. You also learned how to save data locally using React Native Simple Store. In the final part of the series, you’ll learn how to use native device functionality to a React Native app.