CS 466 - React Native Persistence

Due: 2019-11-16

Goals

  • Learn about Redux
  • Learn one technique for local data persistence

Prerequisites

  1. Accept the assignment on our Github Classroom.
  2. Download the git repository to your local computer.
  3. Update the package.json file with your name and email address.
  4. Install the package dependencies with npm install
  5. Install new dependencies: expo install redux react-redux redux-persist

Assignment

As mentioned in class, we are going to pause our work bringing the ghosts to life so we can talk about data. As such, you will find that the starter code is a bit of a regression back to our earlier, pre-graphics state so we can focus on data flow.

Part 1: Create an action producer

Make a new directory at the top level of your project called redux.

Add a file to this new directory called actions.js.

At the moment, we only have one action that we are concerned with: adding a ghost to the collection.

A Redux action is just a JavaScript object with a type and a payload. The type for our ghost adding action will be 'ADD_GHOST' (note that this is a string). The payload will be a ghost object.

While there is no reason we couldn't just use the strings in the raw for the action types, it is typical to make them constants as we scale up, so we will practice that here. Add this line to actions.js:

export const ADD_GHOST = 'ADD_GHOST';

Now, you need to write an action creator. This is just a function that takes in a ghost and returns a JavaScript object with the properties type and payload. Make a function called addGhost in actions.js to do this.

Part 2: Create a reducer

Add a new file called reducers.js to the redux directory.

A reducer is a function that takes in the current state and an action, and returns the new state (which may be unchanged).

Import ADD_GHOST from actions.js.

Create a new function called ghostReducer. It should take in two arguments, state and action.

export default ghostReducer = (state, action) => {
    switch (action.type){
        case ADD_GHOST:
            // calculate newState
            return newState;
        default:
            return state
    }
}

The state value is the current contents of the store. Assume this is an object with a ghosts property, which contains an Array of ghost objects (just like our old state). This function will use action.type to figure out how to incorporate action.payload into the state object. In this case, we only have one action type that we care about: ADD_GHOST. There are actually other action types that will be run through the reducer, so the default is to return the current state untouched.

Fill in this function to add the ghost in action.payload into the state. Remember that we don't edit state directly, so use the same tricks as before to make sure that you have a new state object and that the Array is also a copy rather than the old Array with a new item added (if you don't remember what those are -- go look in App.js).

Part 3: Create the data store

Add one more file: store.js

Obviously this is where we will create the data store. Note that we only get one of these per application.

Import the following to get access to the createStore function and to our new reducer.

import { createStore } from 'redux';

import ghostReducer from './GhostReducer';

The createStore function takes in a reducer and (optionally) an initial state and creates our store. Add these two lines to store.js.

const INITIAL_STATE = {ghosts: []};

export const store = createStore(ghostReducer, INITIAL_STATE);

Part 4: Connecting to the store

In App.js, we are going to add a Provider to wrap our component hierarchy. Import Provider from react-redux:

import { Provider } from 'react-redux';

Then import the store from './redux/store'.

Put a Provider component around the View returned by App. Give the Provider one prop called store and pass it the store variable (i.e., store={store}).

You can now clean up App.js. We no longer need the old addGhost function or the ghosts state, nor do we need the screenProps we were using to pass those props to the other pages. Delete them all.

Part 5: Adding ghosts

Go to RadarScreen.js, where we have been catching ghosts at the push of a button.

Instead of using the old addGhost function that we pulled from screenProps, we are going to use the new addGhost action creator we made and a dispatch hook to send the new action to the reducer.

Remove the line pulling the old addGhost function out of screenProps.

Add imports for useDispatch from 'react-redux' and addGhost from '../redux/actions'.

At the start of the RadarScreen component, call useDispatch() to get the dispatch function:

const dispatch = useDispatch();

Our new addGhost function is now returning an action rather than adding the newly created ghost to our state. So, call dispatch on the newly created action and send it to the reducer.

If you add a console.log to your reducer, you will be able to see the state changes go by.

Part 6: Listing out the ghosts

Now we need to modify the GhostsScreen component to read the ghost list out of our new data store.

We want access to the data in the store, and we also want to be notified when the state changes so we can re-render. There are a couple of ways to do this in redux, but we will use the selector hook.

Import useSelector from 'react-redux'. The selector hook takes in a function that when given the current state, returns the portion we are interested in. Replace the line extracting ghosts from screenProps with this:

const ghosts = useSelector((state) => state.ghosts);

There were a lot of steps, but you are now converted over to redux, and the app should function again... exactly the way it did before. Make sure it works, but there should be no observable changes.

Part 7: Add persistence

Of course, the whole point was to add persistence, which we will do using redux-persist.

The first thing we will do is add a new reducer. Redux allows us to have multiple reducers, each of which gets a chance to look at new actions to see if they know what to do with it. The redux-persist module comes with its own reducer, which we will install.

Go to reducers.js. Add the following imports:

import { persistReducer } from 'redux-persist';
import { AsyncStorage } from 'react-native';

Then, underneath the ghostReducer, add in the persistReducer:

const persistanceConfig = {
    key: 'storage',
    storage: AsyncStorage
}

export default persistedReducer = persistReducer(persistanceConfig, ghostReducer);

Notice that this combines in two reducers and returns a new reducer that combines them (there is another function called combineReducers in redux if you want to combine arbitrary reducers). We want to be exporting this new reducer, so we have made that the new default export. You will need to switch the default export in front of ghostReducer to const. The other thing to notice is that this is being configured to run on top of AsyncStorage.

Now, go to store.js. We are going to make a persistent store (which will live alongside our current store).

Add an import for persistStore from 'redux-persist'.

Then, after the line creating the store, add this line:

export const persistentStore = persistStore(store);

Our final stop will be in App.js.

We need to add an import for the new persistentStore. We also need to import PersistGate, which will wrap our components in a similar way to Provider.

import { PersistGate } from 'redux-persist/integration/react'

The role of the PersistGate is to make sure your components don't load until the state has been unpacked from storage. Use PersistGate to wrap the View, inside of Provider. PersistGate takes two props: loading (which you can set to null or to the replacement component you would like to show while you are waiting) and persistor (which you can set to persistentStore).

Now you should have persistent data. When the app restarts, your list of ghosts hangs around.

Challenge: Clearing the list

Persistance is great, but all we can do is add ghosts. We may want to be able to clear them out as well.

Let's add a Button to clear everything to the top of the GhostsScreen page. We want to give the user the opportunity to back out, so clicking the button should bring up an 'Are you sure?' message.

To implement this, we will need both a Button and an Alert. Import them both from react-native.

Add the following in GhostsScreen.js outside of the GhostsScreen component.

const clearAll = () => {
  Alert.alert(
    'Clear all ghosts?',
    'Cleared ghosts can not be restored',
    [
      {text:'Cancel', style:'cancel'},
      {text: 'Clear', onPress: ()=>{
        // delete ghosts
      }}
    ]
  );
};

const ClearButton = () => {
  return (<Button 
    title="Clear"
    onPress={()=>{clearAll();}}
    />);

};

This creates a button that opens an alert. The user can then cancel, in which case nothing will happen, or clear, which should clear all of the ghosts.

We want to put the button up in the header. However, the header is really owned by the navigator and isn't part of the GhostsScreen component. Fortunately, we can install components into the header through the navigation options. Modify the GhostsScreen.navigationOptions to add the ClearButton to the right side of the header:

GhostsScreen.navigationOptions = {
  title: 'Ghosts',
  headerRight: <ClearButton />
};

This will create a nice button you can click... but it doesn't do anything. Your challenge is to complete the implementation by adding a new action and reducer stage.

Hint: You will not be able to use the dispatch hook since the Alert is created outside of a functional component. However, you can always call store.dispatch(action) to perform the same action. In all honesty, the recommendation is a little more complex. The React Redux library provides a connect() function that creates container components for us to separate logic from presentation elements, and we should probably be using that here. But for the sake of simplicity, we will just use store.dispatch(action)

Finishing up

Commit your changes to git and push them back up to GitHub. I will find them there.

Grading

Points Requirement
✓/✗ Redux is implemented correctly
✓/✗ Persistence works
✓/✗ List can be cleared