Creating a Local File Cache Inside Your React Native App

Article summary

I recently had to implement a local file cache for a React Native app so that users could play audio and video when offline. There are a number of reasons you might need to make a similar caching system, and this post explains how to set up the basic code.

The only libraries involved here are Redux and rn-fetch-blob, but you can likely use substitutes like MobX, react-native-fs, or whatever is already available in your NPM packages.

This example also uses TypeScript. If you’re using plain JavaScript, just plan on copy/pasting less code, but writing the appropriate unit tests 😉.

Our file caching system will have two main parts. The first is a React component, which will wrap around RNFetchBlob’s functionality and respond to changes in the Redux store. The second is a set of actions and reducers on the Redux store which deal specifically with file caching.

Let’s begin with the Redux side of things:

The Redux Code

Beginning with types

Add the following property to your state type (or whatever state structure your Redux store is managing):

 
export type State = {
  //...
  fileCacheMap: FileCacheMap
  //...
}

The `FileCacheMap` is going to be an object whose keys are URLs corresponding to the files you want to cache. The value at each key will represent the status of the individual file cache. Define the `FileCacheMap` as follows:


export type FileCacheMap = {
  [key: string]: FileCacheStatus | undefined;
}

Next, define the `FileCacheStatus` type, which is a union of four other simple types:


export type FileCacheStatus =
  | FileCacheRequested
  | FileCacheInProgress
  | FileCacheSucceeded
  | FileCacheFailed;

type FileCacheRequested = {
  type: "FileCacheRequested";
};
type FileCacheInProgress = {
  type: "FileCacheInProgress";
};
type FileCacheSucceeded = {
  type: "FileCacheSucceeded";
  localUrl: string;
};
type FileCacheFailed = {
  type: "FileCacheFailed";
};

Because we will only be able to resolve a local URL if the caching actually succeeds, only the `FileCacheSucceeded` type includes a `localUrl` field.

Next, let’s define the Redux actions that we’ll need to signal changes about file cache statuses:


export type RequestFileCache = {
  type: "RequestFileCache";
  key: string;
};

export type BeginFileCache = {
  type: "BeginFileCache";
  key: string;
};

export type CompleteFileCache = {
  type: "CompleteFileCache";
  key: string;
  localUrl: string;
};

export type FailFileCache = {
  type: "FailFileCache";
  key: string;
};

export type RemoveFileCache = {
  type: "RemoveFileCache";
  key: string;
};

export type FileCacheAction = 
  | RequestFileCache 
  | BeginFileCache 
  | CompleteFileCache 
  | FailFileCache 
  | RemoveFileCache

Notice that these correspond pretty closely to the `FileCacheStatus` types.

Now that all of our types are defined, let’s write the reducer function:

The reducer


export const fileCacheReducer = (state: State, action: FileCacheAction): State => {
  switch (action.type) {
    case "RequestFileCache":
      if (!canRequestCacheForUrl(action.key, state.fileCacheMap)) {
        return state;
      }
      return setCacheStatus(
        action.key,
        { type: "FileCacheRequested" },
        state
      );
    case "CompleteFileCache":
      return setCacheStatus(
        action.key,
        {
          type: "FileCacheSucceeded",
          localUrl: action.localUrl,
        },
        state
      );
    case "FailFileCache":
      return setCacheStatus(
        action.key,
        { type: "FileCacheFailed" },
        state
      );
    case "RemoveFileCache":
      return setCacheStatus(action.key, undefined, state);
  }
};

There are two helper functions used here (`canRequestCacheForKey` and `setCacheStatus). Go ahead and implement them beneath the reducer or in some utility module as follows:

Utility functions

`canRequestCacheForKey` is basically a conditional guard to keep us from sending unnecessary caching requests:


export const canRequestCacheForKey = (
  key: string,
  fileCacheMap: FileCacheMap
): boolean => {
  const currentStatus = fileCacheMap[url];
  return (
    // If there's no current status, then we haven't tried caching the file yet
    !currentStatus ||
    // If we're currently trying to cache the file or have already cached it, don't send a request to cache it again
    (currentStatus.type !== "FileCacheInProgress" &&
      currentStatus.type !== "FileCacheSucceeded")
  );
};

The `setCacheStatus` function simply overwrites the file cache with the given status and returns the whole Redux state. There are prettier ways to do this, but this method is simple.


export const setCacheStatus = (
  url: string,
  status: FileCacheStatus | undefined,
  state: State
): State => {
  return {
    ...state,
    fileCacheMap: {
      ...state.fileCacheMap,
      [url: status]
    }
  }
};

Now that we’ve added our Redux state, actions, and reducers, it’s time to make our React component (actually, both a container component and an inner component).

The React Code

First, let’s look at the inner component. This is sort of like a presentational component, but it always renders `null` and thus doesn’t present anything. You could probably implement this as a Redux store subscriber, but having access to the lifecycle hooks and component state here is definitely useful.

The inner component


type Props = {
  appCache: FileCacheMap;
  // These functions wrap around the Redux `dispatch` function, as you'll see in the container component.
  beginFileCache: (url: string) => void;
  completeFileCache: (originalUrl: string, localUrl: string) => void;
  failFileCache: (url: string) => void;
  removeFileCache: (url: string) => void;
};

type State = {
  requestQueue: string[];
};

export class FileCache extends PureComponent {
  state: State = {
    requestQueue: [],
  };

  render() {
    return null;
  }

  componentDidUpdate(prevProps: Props, prevState: State) {
    const changedKeys = cacheDiff(prevProps.appCache, this.props.appCache);
    changedKeys.forEach(key => {
      const value = this.props.appCache[key];
      if (value && value.type === "FileCacheRequested") {
        if (!requestQueueContainsKey(this.state.requestQueue, key)) {
          this.handleFileCacheRequest(key);
        }
      } 
    });
  }

  handleFileCacheRequest(url: string) {
    this.addRequestToQueue(url);
    RNFetchBlob.config({
      path: RNFetchBlob.fs.dirs.DocumentDir + "/" + localFilenameForUrl(url),
    })
      .fetch("GET", url)
      .then(result => {
        this.props.completeFileCache(url, result.path());
        this.removeRequestFromQueue(url);
      })
      .catch(error => {
        this.props.failFileCach(url);
        this.removeRequestFromQueue(url);
      });
  }

  addRequestToQueue(url: string) {
    const newQueue = this.state.requestQueue.concat([url])
    this.setState({
      requestQueue: newQueue,
    });
  }

  removeRequestFromQueue(url: string): void {
    const newQueue = this.state.requestQueue.filter(el => el !== element);
    this.setState({
      requestQueue: newQueue,
    });
  }
}

There are a few helper functions used here that I’ll leave for you to implement. There’s also plenty of room for customization, e.g. cleaning up the cache at regular intervals and making sure that the Redux store and file system don’t get out-of-sync.

The container component

This is a pretty straightforward connected component that sends a few dispatch-y properties into its child component, along with a copy of the `fileCacheMap`:


export const FileCacheContainer = connect(
  state => ({
    appCache: state.fileCacheMap,
  }),
  dispatch => ({
    beginFileCache: (url: string) =>
      dispatch({
        type: "BeginFileCache",
        key: url,
      }),
    completeFileCache: (originalUrl: string, localUrl: string) =>
      dispatch({
        type: "CompleteFileCache",
        key: originalUrl,
        localUrl: localUrl,
      }),
    failFileCache: (url: string) =>
      dispatch({
        type: "FailFileCache",
        key: url,
      }),
    removeFileCache: (url: string) =>
      dispatch({
        type: "RemoveFileCache",
        key: url,
      }),
  })
)(FileCache);

And that’s it! I won’t write any code with other components that actually use the file cache map, because that could vary greatly across use cases. However, you’ll certainly want a function or two which look into the file cache map to resolve a local URL. If the status of that cache is FileCacheFailed instead of FileCacheSucceeded, you should default to using the original remote URL, which just happens to be the key in the map.

If you ever need to cache some files with a React Native app, I hope this tutorial helps things go smoothly!

Conversation
  • TomW says:

    What does that localfileurl function look like? I am trying to put together a similar solution.

    • Aaron King Aaron says:

      Hey Tom,
      I just found the code that inspired this post, and I think what you’re looking for is something like this:

      fullLocalPathForRemoteUrl(remoteUrl: string) {
      const uuid = uuidv4();
      const localPath LOCAL_PATH_PREFIX + "/" + uuid + "." + fileExtForUrl(remoteUrl);
      return RNFetchBlob.fs.dirs.CacheDir + "/" + localPath;
      }

      `RNFetchBlob`: https://github.com/joltup/rn-fetch-blob
      `uuidv4()`: https://github.com/uuidjs/uuid
      And of course `LOCAL_PATH_PREFIX` and `fileExtForUrl` are implemented elsewhere in the code.

      You could also try writing a hash function that maps a remote URL to some other string format, instead of using a random UUID.

      • Tom W says:

        Thanks for this Aaron, unfortunately I am using MPEG-DASH/HLS streaming format for the video content to protect with Widevine/FairPlay so I need to maintain file structure when downloading for offline playback. I was hoping your solution might be doing the same, appreciated none the less!

  • Comments are closed.