Build an image browsing App with React Native (3) — Persistent Storage

Kai Xie
Geek Culture
Published in
9 min readJun 7, 2021

React Native brings React’s declarative UI framework to iOS and Android. With React Native, you use native UI controls and have full access to the native platform. (https://github.com/facebook/react-native)

We have built up an Image browsing App at

and

In these two articles, we have implemented all essential functions such as fetching image data from backend API, displaying the image in a list, navigating to the detail page of a single image while the user clicks it from the list, and also keep and read data to/from Redux store.

But you might notice the App would display nothing when you launch the app even though you have run the app and input the keyword and browsed some images before. That’s because we don’t have persistent storage to keep the data and these images, and when you kill the App, everything is gone.

So in this tutorial, we will add the persistent storage and save the data in it, and then the app would show saved images when the app is launched and wait for the user’s keyword.

actually, we won’t save the images themselves technically because they might be pretty large. We would save the data from backend API, including all URLs of the images instead.

There are lots of ways to implement persistent storage on mobile devices with React Native. the easiest way might be with React Native AsyncStorage. with AsyncStorage, we can easily save some data with key-value pairs on mobile devices. But in this tutorial, I would introduce another way, with WatermelonDB to implement the local storage.

WatermelonDB is a reactive database framework that implements a new way of dealing with user data in React Native and React web apps.

So let’s start.

Install WatermelonDB

Please follow the https://nozbe.github.io/WatermelonDB/Installation.html to install the WatermelonDB.

Create the schema

Let’s create the schema for the WatermelonDB first. I’d suggest creating a folder, database in the src folder, and put all database-related code in this folder.

And let’s create a new file, Schema.ts in this folder, and add a new schema as followed:

import {appSchema, tableSchema} from '@nozbe/watermelondb';export default appSchema({
version: 1,
tables: [
tableSchema({
name: 'photos',
columns: [
{name: 'photo_id', type: 'number'},
{name: 'width', type: 'number', isOptional: true},
{name: 'height', type: 'number', isOptional: true},
{name: 'url', type: 'string', isOptional: true},
{name: 'photographer', type: 'string', isOptional: true},
{name: 'photographer_url', type: 'string', isOptional: true},
{name: 'photographer_id', type: 'number', isOptional: true},
{name: 'avg_color', type: 'string', isOptional: true},
{name: 'src', type: 'string', isOptional: true},
{name: 'liked', type: 'boolean', isOptional: true},
{name: 'keyword', type: 'string', isOptional: true},
],
}),
],
});

In this schema, we defined a table, photos in the database, and also defined several columns in this table with the proper type and if it is optional.

Create the Model

And we also need to create a new data model accordingly. So let’s create a new file, Photo.ts, and add the following data model:

import {Model} from '@nozbe/watermelondb';
import {field} from '@nozbe/watermelondb/decorators';
export default class PhotoModel extends Model {
static table = 'photos';
@field('photo_id') photo_id: number;
@field('width') width: number;
@field('height') height: number;
@field('url') url: string;
@field('photographer') photographer: string;
@field('photographer_url') photographerUrl: string;
@field('photographer_id') photographerId: number;
@field('avg_color') avgColor: string;
@field('src') src: string;
@field('liked') liked: boolean;
@field('keyword') keyword: string;
}

This model class represents a type of thing in our app.

Implement the Database operation

Then we need to create a database instance and implement some CRUD methods.

So let’s create a new file, Database.ts, and put the following code in it

import {Database} from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import schema from './Schema';
import PhotoModel from './Photo';
// First, create the adapter to the underlying database:
const adapter = new SQLiteAdapter({
schema,
// experimental JSI mode, a more advanced version of synchronous: true
jsi: false,
// Optional, but you should implement this method:
onSetUpError: error => {
// Database failed to load -- offer the user to reload the app or log out
},
});
// Then, make a Watermelon database from it!
const database = new Database({
adapter,
modelClasses: [PhotoModel],
actionsEnabled: true,
});
export const fetchAll = async (): Promise<Photo[]> => {
const photosCollection = database.get('photos');
const photos = (await photosCollection.query().fetch()) as Array<PhotoModel>;
return photos.map((photoModel: PhotoModel) => {
const photo = {
id: photoModel.photo_id,
width: photoModel.width,
height: photoModel.height,
url: photoModel.url,
photographer: photoModel.photographer,
photographer_url: photoModel.photographerUrl,
photographer_id: photoModel.photographerId,
avg_color: photoModel.avgColor,
src: JSON.parse(photoModel.src),
liked: photoModel.liked,
keyword: photoModel.keyword,
};
return photo;
});
};
export const insertAll = async (photos: Array<Photo>) => {
const photosCollection = database.get('photos');
await database.action(async () => {
await database.batch(
...photos.map((photo: Photo) => {
const newPhoto = photosCollection.prepareCreate(
(photoModel: PhotoModel) => {
photoModel.photo_id = photo.id;
photoModel.width = photo.width;
photoModel.height = photo.height;
photoModel.url = photo.url;
photoModel.photographer = photo.photographer;
photoModel.photographerUrl = photo.photographer_url;
photoModel.photographerId = photo.photographer_id;
photoModel.avgColor = photo.avg_color;
photoModel.src = JSON.stringify(photo.src);
photoModel.liked = photo.liked;
photoModel.keyword = photo.keyword;
},
);
return newPhoto;
}),
);
});
};
export const clearAll = async () => {
const photosCollection = database.get('photos');
const photos = await photosCollection.query().fetch();
await database.action(async () => {
photos.forEach(async (item: {markAsDeleted: () => any}) => {
await item.markAsDeleted(); // syncable
// await item.destroyPermanently(); // permanent
});
});
};

As this piece of code shown, we implemented an adapter, a database instance according to the schema and model we defined previously, and implemented 3 methods, fetchAll, with which we can fetch all pictures from the database; insertAll, with which we can add some new pictures into the database, and clearAll, with which we can delete all pictures in the database.

We have implemented the essential parts of saving/fetching data from the local database with WatermelonDB so far, so we will implement the persistent storage with our database model in the chapter.

Separate the data fetching

Before continuing the next step, I’d like to introduce an important principle in software development, Single source of truth (SSOT).

In information systems design and theory, single source of truth is the practice of structuring information models and associated data schema such that every data element is mastered in only one place. Any possible linkages to this data element are by reference only.

But we would have two sources of data, the backend API and the database after we introduced the persistent storage, So I would modify our code to make the database the single source of truth.

According to the above explanation, I’d like to separate the data fetching function from the Home component because we should not call this function directly from any component according to the single source of truth.

You might notice that we implemented the fetchData method inside the Home component and mixed it with the React Hooks in the current code, so let’s separate it from Home component for sake of better structure.

Let’s create a new folder, network in the src folder, and create a new file, FetchData.ts in it, and move the fetchData method to this file ane make some minor changes as shown

const requests = new Set();
const fetchData = async (
......
) => {
......
const json = await response.json();
return json;
} catch (error) {
throw error;
} finally {
requests.delete(url);
}
};
export default fetchData;

As we see, we changed the fetchData function to a pure function as it accepts particular parameters and returns a JSON object or throws an exception. It is not related to any component or lifecycle. We will move these parts to an abstract repository module.

Add Repository

And then let’s implement this abstract repository module next.

So let’s create a new folder, repository in the src folder, and create a new file Repository.ts in this folder, and add the following code to it

import {insertAll, clearAll, fetchAll} from '../database/Database';
import fetchData from '../network/FetchData';
import {FETCH_SUCCEED, FETCH_FAILED, GET_SUCCEED} from '../redux/Action';
import {store} from '../App';
export default class Repository {
static data: Data;
static cachedData?: Photo[];

static get = async () => {
Repository.cachedData = await fetchAll();
console.log(
'get',
Repository.cachedData.map(item => item.id),
);
store.dispatch({
type: GET_SUCCEED,
payload: {...Repository.data, photos: Repository.cachedData},
});
};
static refresh = async (query: string = '', pageIndex: number = 0) => {
if (query === '') {
return;
}
try {
const ids = new Set(Repository.cachedData?.map(item => item.id));
Repository.data = await fetchData(query, pageIndex);
Repository.data?.photos.forEach(item => (item.keyword = query));
// console.log('refresh, ids', ids);
const newPhotos =
Repository.data &&
Repository.data?.photos &&
Repository.data.photos.filter(item => !ids.has(item.id));
if (newPhotos && newPhotos.length > 0) {
console.log(
'insert',
newPhotos.map(item => item.id),
);
insertAll(newPhotos);
}
store.dispatch({
type: FETCH_SUCCEED,
payload: {
...Repository.data,
photos: Repository.cachedData,
},
});
} catch (error) {
store.dispatch({type: FETCH_FAILED, payload: error})
}
};
static clear = async () => {
await clearAll();
};
}

As we see, we implemented three methods in this Repository class, get() to fetch saved data from the persistent storage, refresh() to fetch new data from backend API and save to the persistent storage, and clear() to clear the database.

You might also notice there is a new action, GET_SUCCEED in this piece of code. That is because we need to separate two actions to differentiate the success of the data fetching from backend API and the success of data fetching from the persistent storage.

So we need to add the following code into Action.d.ts

interface GetAction {
type: string;
payload: Data | Error | undefined;
}

and following code into Action.ts

export const GET_SUCCEED = 'GET_SUCCESS';......
export const getSucceed = (data: Data): GetAction => ({
type: GET_SUCCEED,
payload: data,
});

and update the Reducer.ts as

import {FETCH_FAILED, FETCH_SUCCEED, GET_SUCCEED} from '../redux/Action';const INITIAL_STATE: ReducerStateType = {
data: undefined,
error: undefined,
};
const isData = (object: any): object is Data => object;
const fetchReducer = (
state = INITIAL_STATE,
action: FetchAction,
): ReducerStateType => {
switch (action.type) {
case GET_SUCCEED:
if (isData(action.payload)) {
state = {
...state,
data: {
...action.payload,
photos: action.payload?.photos,
},
};
}
break;
case FETCH_SUCCEED:
if (isData(action.payload)) {
state = {
...state,
data: {
...action.payload,
photos: state.data?.photos,
},
error: undefined,
};
}
break;
case FETCH_FAILED:
......
}
return state;
};

As we see, we would handle GET_SUCCEED and FETCH_SUCCEED differently.

Update the Home component to get data from SSOT — Repository

And then the last step is to combine all modules we’ve implemented above together. So let’s update the Home component as follows:

import Repository from '../repository/Repository';
......
const Home = ({navigation}) => {
const data: Data = useSelector(state => state.fetch.data);
const error: Error = useSelector(state => state.fetch.error);
const nextPage = async () => {
const key = 'page';
if (data?.next_page) {
const page = decodeURIComponent(
......
);
await Repository.refresh(data.photos[0].keyword, parseInt(page, 10) || 0);
Repository.get();
}
};
const onSearch = async (query: string) => {
await Repository.clear();
await Repository.refresh(query, 0);
await Repository.get();
};
useEffect(() => {
if (!data && !error) {
(async () => {
await Repository.get();
})();
}
});
......

You would find we are calling the Repository.get() in the Home component when the App launches. And The repository would fetch some saved data from the persistent storage and dispatches a GET_SUCCEED action. And the reducer would update the state once it receives this action.

In another scenario, the Repository.refresh() would be triggered if the user inputs a search keyword, then the repository would clear the persistent storage and fetches the new data from the backend API, and save the new data into the persistent storage. And after that, another Repository.get() would be triggered, then the state would be updated, which is the same as the App launching.

The third scenario is the Repository.refresh() would be triggered once the user scrolls to the bottom of the list, then the repository would fetch the next page of pictures and attaches it to the current list, and update the state.

Now the persistent storage is the single source of truth, any new data from the backend API would be saved into persistent storage first, and then update the state, and then update the view.

Now let’s run the App, you would see like below

We fetched some gorilla pictures and killed the App, and launched the App again, and we would see the gorilla pictures are displayed without inputting any search keyword.

Now we have implemented persistent storage with WatermelonDB on React Native, which lets you are able to display some old data instead of an empty view even if the network is inaccessible or the backend API is down. It might improve the user experience in some scenarios.

There are also some other places that could be improved like we are using FlatList to display the images, but the FlatList might consume lots of memory, So We will optimize it with RecyclerListView in

Welcome to read this article, and any comments are welcome.

--

--