Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Hook-based library for simple yet flexible data fetching and display in React ap
- Supports chaining API multiple dependent API requests.
- Opt-in fine-grained control of a response cache,
with clear identification of stale responses and their age.
- **TODO**: A global cache,
which can persist across screen unmounts.
- Opt-in global cache,
which persists responses across screen unmounts.

## Installation

Expand Down Expand Up @@ -124,10 +124,24 @@ or in the top level `ApiReadProvider` `config` prop to set the defaults for all

- **`reader`**: `(path: string) => Promise<mixed>` -
A reader function for your app's API, as described above.
- **`staleWhileMounted?`**: `boolean` -
If `true`, responses are kept in a global cache owned by the `ApiReadProvider`,
and a hook mounting on a path which is already in the cache
immediately returns the cached response (marked stale, with a `staleReason` of `'mounted'`)
while the fresh response is fetched in the background.
This avoids a loading state when the user returns to a screen they've already visited.
Cached responses are dropped when the path is invalidated,
and updated in place when the path is mutated,
so an unmounted screen can't be brought back with data you've already replaced.
Note that this option has no effect without an `ApiReadProvider`, which owns the cache.
The cache currently has no eviction policy:
it holds one entry per path read for the lifetime of the provider,
so avoid enabling it globally in an app which reads an unbounded number of distinct paths.
- **`staleWhileInvalidated?`**: `boolean` -
If `true`, then when an API response is considered invalidated
(either because you manually invalidated it, or it's become too old),
the stale (invalidated) response will continue to be returned by the `useApiRead` hook.
A response this hook read itself takes precedence over anything in the global cache.
- **`staleWhileError?`**: `boolean` -
If `true`, then when an API response returns an error,
if there was a previous response returned by this hook,
Expand Down Expand Up @@ -158,7 +172,7 @@ The following properties tell you about the data returned:
this can be used to display a relevant error message to the user.
- **`stale`**: `boolean` -
Will be set to `true` if the current `data` being returned is considered stale.
- **`staleReason`**: `null | 'invalidated' | 'error'` -
- **`staleReason`**: `null | 'mounted' | 'invalidated' | 'error'` -
If a stale response is being returned (`stale: true`),
indicates why.
- **`receivedAt`**: `null | number` -
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"react": "*"
},
"jest": {
"preset": "react-native",
"modulePathIgnorePatterns": [
"<rootDir>/example/node_modules",
"<rootDir>/lib/"
Expand Down
118 changes: 93 additions & 25 deletions src/ApiReadContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,25 @@ type MountedEntries = {
};
};

export type CacheEntry = { data: unknown; receivedAt: number };
type Cache = { [path: string]: CacheEntry };

type PathPredicate = (path: string) => boolean;

function exactly(search: string): PathPredicate {
return function predicate(path) {
return path === search;
};
}

function matching(search: string | RegExp): PathPredicate {
return function predicate(path) {
return Boolean(path.match(search));
};
}

export const ApiReadContext = React.createContext<{
hasProvider: boolean;
config: ReadConfig;
addMountedEntry: (
key: string,
Expand All @@ -20,14 +38,21 @@ export const ApiReadContext = React.createContext<{
mutate: (mutator: Mutator) => void
) => void;
removeMountedEntry: (key: string) => void;
getCached: (path: string) => CacheEntry | undefined;
setCached: (path: string, data: unknown, receivedAt: number) => void;
clearCached: (path: string) => void;
invalidateExact: (search: string) => void;
invalidateMatching: (search: string | RegExp) => void;
mutateExact: (search: string, mutator: Mutator) => void;
mutateMatching: (search: string | RegExp, mutator: Mutator) => void;
}>({
hasProvider: false,
config: {},
addMountedEntry: () => {},
removeMountedEntry: () => {},
getCached: () => undefined,
setCached: () => {},
clearCached: () => {},
invalidateExact: () => {},
invalidateMatching: () => {},
mutateExact: () => {},
Expand All @@ -41,6 +66,7 @@ type Props = {

export function ApiReadProvider({ config, children }: Props) {
const mountedEntriesRef = React.useRef<MountedEntries>({});
const cacheRef = React.useRef<Cache>({});

const addMountedEntry = React.useCallback(function addMountedEntry(
key: string,
Expand All @@ -59,58 +85,100 @@ export function ApiReadProvider({ config, children }: Props) {
},
[]);

const invalidateExact = React.useCallback(function invalidatExact(
search: string
const getCached = React.useCallback(function getCached(
path: string
): CacheEntry | undefined {
return cacheRef.current[path];
},
[]);

const setCached = React.useCallback(function setCached(
path: string,
data: unknown,
receivedAt: number
): void {
for (const entry of Object.values(mountedEntriesRef.current)) {
if (entry.path === search) {
entry.invalidate();
}
}
cacheRef.current[path] = { data, receivedAt };
},
[]);

const invalidateMatching = React.useCallback(function invalidateMatching(
search: string | RegExp
const clearCached = React.useCallback(function clearCached(
path: string
): void {
for (const entry of Object.values(mountedEntriesRef.current)) {
if (entry.path.match(search)) {
entry.invalidate();
}
}
delete cacheRef.current[path];
},
[]);

const mutateExact = React.useCallback(function mutateExact(
search: string,
mutator: Mutator
// Invalidation drops cached responses as well as notifying mounted hooks, so
// that a path which is remounted later can't be served data already known to
// be out of date.
const invalidateWhere = React.useCallback(function invalidateWhere(
predicate: PathPredicate
): void {
for (const path of Object.keys(cacheRef.current)) {
if (predicate(path)) delete cacheRef.current[path];
}
for (const entry of Object.values(mountedEntriesRef.current)) {
if (entry.path === search) {
entry.mutate(mutator);
}
if (predicate(entry.path)) entry.invalidate();
}
},
[]);

const mutateMatching = React.useCallback(function mutateMatching(
search: string | RegExp,
// Mounted hooks write their mutated data back to the cache, so applying the
// mutator to a path which is both mounted and cached converges on the same
// result rather than applying twice.
const mutateWhere = React.useCallback(function mutateWhere(
predicate: PathPredicate,
mutator: Mutator
): void {
for (const entry of Object.values(mountedEntriesRef.current)) {
if (entry.path.match(search)) {
entry.mutate(mutator);
for (const [path, cached] of Object.entries(cacheRef.current)) {
if (predicate(path)) {
cacheRef.current[path] = { ...cached, data: mutator(cached.data) };
}
}
for (const entry of Object.values(mountedEntriesRef.current)) {
if (predicate(entry.path)) entry.mutate(mutator);
}
},
[]);

const invalidateExact = React.useCallback(
function invalidateExact(search: string): void {
invalidateWhere(exactly(search));
},
[invalidateWhere]
);

const invalidateMatching = React.useCallback(
function invalidateMatching(search: string | RegExp): void {
invalidateWhere(matching(search));
},
[invalidateWhere]
);

const mutateExact = React.useCallback(
function mutateExact(search: string, mutator: Mutator): void {
mutateWhere(exactly(search), mutator);
},
[mutateWhere]
);

const mutateMatching = React.useCallback(
function mutateMatching(search: string | RegExp, mutator: Mutator): void {
mutateWhere(matching(search), mutator);
},
[mutateWhere]
);

return (
<ApiReadContext.Provider
value={{
hasProvider: true,
config,
addMountedEntry,
removeMountedEntry,
getCached,
setCached,
clearCached,
invalidateExact,
invalidateMatching,
mutateExact,
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
it.todo('write a test');
it.todo('cover useApiRead mount/unmount/remount against the cache');
Loading