Recoil is an experimental state management framework for React applications that provides atoms and selectors for fine-grained reactivity.
—
System for handling async state with loading, error, and success states. Loadables provide a unified interface for working with synchronous values, promises, and error states without using React suspense boundaries.
Core loadable interface and its variants for different states.
/**
* Discriminated union representing the state of async operations
*/
type Loadable<T> = ValueLoadable<T> | LoadingLoadable<T> | ErrorLoadable<T>;
interface BaseLoadable<T> {
/** Get the value, throwing if not available */
getValue: () => T;
/** Convert to a Promise */
toPromise: () => Promise<T>;
/** Get value or throw error/promise */
valueOrThrow: () => T;
/** Get error or throw if not error state */
errorOrThrow: () => any;
/** Get promise or throw if not loading */
promiseOrThrow: () => Promise<T>;
/** Check equality with another loadable */
is: (other: Loadable<any>) => boolean;
/** Transform the loadable value */
map: <S>(map: (from: T) => Loadable<S> | Promise<S> | S) => Loadable<S>;
}
interface ValueLoadable<T> extends BaseLoadable<T> {
state: 'hasValue';
contents: T;
/** Get value if available, undefined otherwise */
valueMaybe: () => T;
/** Get error if available, undefined otherwise */
errorMaybe: () => undefined;
/** Get promise if available, undefined otherwise */
promiseMaybe: () => undefined;
}
interface LoadingLoadable<T> extends BaseLoadable<T> {
state: 'loading';
contents: Promise<T>;
valueMaybe: () => undefined;
errorMaybe: () => undefined;
promiseMaybe: () => Promise<T>;
}
interface ErrorLoadable<T> extends BaseLoadable<T> {
state: 'hasError';
contents: any;
valueMaybe: () => undefined;
errorMaybe: () => any;
promiseMaybe: () => undefined;
}Usage Examples:
import React from 'react';
import { useRecoilValueLoadable } from 'recoil';
// Component handling all loadable states
function AsyncDataDisplay({ dataState }) {
const dataLoadable = useRecoilValueLoadable(dataState);
switch (dataLoadable.state) {
case 'hasValue':
return <div>Data: {JSON.stringify(dataLoadable.contents)}</div>;
case 'loading':
return <div>Loading...</div>;
case 'hasError':
return <div>Error: {dataLoadable.contents.message}</div>;
}
}
// Using loadable methods
function LoadableMethodsExample({ dataState }) {
const dataLoadable = useRecoilValueLoadable(dataState);
// Safe value access
const value = dataLoadable.valueMaybe();
const error = dataLoadable.errorMaybe();
const promise = dataLoadable.promiseMaybe();
return (
<div>
{value && <div>Value: {JSON.stringify(value)}</div>}
{error && <div>Error: {error.message}</div>}
{promise && <div>Loading...</div>}
</div>
);
}
// Transform loadable values
function TransformedLoadable({ userState }) {
const userLoadable = useRecoilValueLoadable(userState);
// Transform the loadable to get display name
const displayNameLoadable = userLoadable.map(user =>
user.displayName || user.email || 'Anonymous'
);
if (displayNameLoadable.state === 'hasValue') {
return <div>Welcome, {displayNameLoadable.contents}!</div>;
}
return <div>Loading user...</div>;
}Factory functions and utilities for creating and working with loadables.
namespace RecoilLoadable {
/**
* Factory to make a Loadable object. If a Promise is provided the Loadable will
* be in a 'loading' state until the Promise is either resolved or rejected.
*/
function of<T>(x: T | Promise<T> | Loadable<T>): Loadable<T>;
/**
* Factory to make a Loadable object in an error state
*/
function error(x: any): ErrorLoadable<any>;
/**
* Factory to make a loading Loadable which never resolves
*/
function loading(): LoadingLoadable<any>;
/**
* Factory to make a Loadable which is resolved when all of the Loadables provided
* to it are resolved or any one has an error. The value is an array of the values
* of all of the provided Loadables. This is comparable to Promise.all() for Loadables.
*/
function all<Inputs extends any[] | [Loadable<any>]>(inputs: Inputs): Loadable<UnwrapLoadables<Inputs>>;
function all<Inputs extends {[key: string]: any}>(inputs: Inputs): Loadable<UnwrapLoadables<Inputs>>;
/**
* Returns true if the provided parameter is a Loadable type
*/
function isLoadable(x: any): x is Loadable<any>;
}
type UnwrapLoadables<T extends any[] | { [key: string]: any }> = {
[P in keyof T]: UnwrapLoadable<T[P]>;
};
type UnwrapLoadable<T> = T extends Loadable<infer R> ? R : T extends Promise<infer P> ? P : T;Usage Examples:
import { RecoilLoadable, selector } from 'recoil';
// Create loadables from various inputs
const exampleSelector = selector({
key: 'exampleSelector',
get: () => {
// From value
const valueLoadable = RecoilLoadable.of('hello');
// From promise
const promiseLoadable = RecoilLoadable.of(
fetch('/api/data').then(r => r.json())
);
// Error loadable
const errorLoadable = RecoilLoadable.error(new Error('Something went wrong'));
// Loading loadable that never resolves
const loadingLoadable = RecoilLoadable.loading();
return { valueLoadable, promiseLoadable, errorLoadable, loadingLoadable };
},
});
// Combine multiple loadables
const combinedDataSelector = selector({
key: 'combinedDataSelector',
get: async ({get}) => {
const userLoadable = get(noWait(userState));
const settingsLoadable = get(noWait(settingsState));
const preferencesLoadable = get(noWait(preferencesState));
// Wait for all to resolve
const combinedLoadable = RecoilLoadable.all([
userLoadable,
settingsLoadable,
preferencesLoadable,
]);
if (combinedLoadable.state === 'hasValue') {
const [user, settings, preferences] = combinedLoadable.contents;
return { user, settings, preferences };
}
// Propagate loading or error state
return combinedLoadable.contents;
},
});
// Combine object of loadables
const dashboardDataSelector = selector({
key: 'dashboardDataSelector',
get: async ({get}) => {
const loadables = {
user: get(noWait(userState)),
posts: get(noWait(postsState)),
notifications: get(noWait(notificationsState)),
};
const combinedLoadable = RecoilLoadable.all(loadables);
if (combinedLoadable.state === 'hasValue') {
return {
...combinedLoadable.contents,
summary: `${combinedLoadable.contents.posts.length} posts, ${combinedLoadable.contents.notifications.length} notifications`,
};
}
throw combinedLoadable.contents;
},
});
// Type checking
function processUnknownValue(value: unknown) {
if (RecoilLoadable.isLoadable(value)) {
switch (value.state) {
case 'hasValue':
console.log('Loadable value:', value.contents);
break;
case 'loading':
console.log('Loadable is loading');
break;
case 'hasError':
console.log('Loadable error:', value.contents);
break;
}
} else {
console.log('Not a loadable:', value);
}
}Common patterns for working with loadables in complex scenarios.
Usage Examples:
import React from 'react';
import { RecoilLoadable, selector, useRecoilValue } from 'recoil';
// Fallback chain with loadables
const dataWithFallbackSelector = selector({
key: 'dataWithFallbackSelector',
get: ({get}) => {
const primaryLoadable = get(noWait(primaryDataState));
const secondaryLoadable = get(noWait(secondaryDataState));
const cacheLoadable = get(noWait(cacheDataState));
// Try primary first
if (primaryLoadable.state === 'hasValue') {
return RecoilLoadable.of({
data: primaryLoadable.contents,
source: 'primary',
});
}
// Try secondary
if (secondaryLoadable.state === 'hasValue') {
return RecoilLoadable.of({
data: secondaryLoadable.contents,
source: 'secondary',
});
}
// Use cache as last resort
if (cacheLoadable.state === 'hasValue') {
return RecoilLoadable.of({
data: cacheLoadable.contents,
source: 'cache',
stale: true,
});
}
// All are loading or have errors
if (primaryLoadable.state === 'loading' ||
secondaryLoadable.state === 'loading') {
return RecoilLoadable.loading();
}
// Return the primary error as it's most important
return RecoilLoadable.error(primaryLoadable.contents);
},
});
// Partial data accumulator
const partialDataSelector = selector({
key: 'partialDataSelector',
get: ({get}) => {
const loadables = {
essential: get(noWait(essentialDataState)),
important: get(noWait(importantDataState)),
optional: get(noWait(optionalDataState)),
};
const result = {
essential: null,
important: null,
optional: null,
status: 'partial',
};
// Must have essential data
if (loadables.essential.state !== 'hasValue') {
if (loadables.essential.state === 'hasError') {
return RecoilLoadable.error(loadables.essential.contents);
}
return RecoilLoadable.loading();
}
result.essential = loadables.essential.contents;
// Include other data if available
if (loadables.important.state === 'hasValue') {
result.important = loadables.important.contents;
}
if (loadables.optional.state === 'hasValue') {
result.optional = loadables.optional.contents;
}
// Mark as complete if we have everything
if (result.important && result.optional) {
result.status = 'complete';
}
return RecoilLoadable.of(result);
},
});
// Loadable transformation chain
const processedDataSelector = selector({
key: 'processedDataSelector',
get: ({get}) => {
const dataLoadable = get(noWait(rawDataState));
// Chain transformations on the loadable
return dataLoadable
.map(data => data.filter(item => item.active))
.map(data => data.map(item => ({
...item,
displayName: item.name.toUpperCase(),
})))
.map(data => data.sort((a, b) => a.priority - b.priority));
},
});
// Component with sophisticated loadable handling
function SmartDataComponent() {
const dataLoadable = useRecoilValue(noWait(partialDataSelector));
if (dataLoadable.state === 'loading') {
return <div>Loading essential data...</div>;
}
if (dataLoadable.state === 'hasError') {
return <div>Failed to load: {dataLoadable.contents.message}</div>;
}
const data = dataLoadable.contents;
return (
<div>
<div>Essential: {JSON.stringify(data.essential)}</div>
{data.important ? (
<div>Important: {JSON.stringify(data.important)}</div>
) : (
<div>Loading important data...</div>
)}
{data.optional ? (
<div>Optional: {JSON.stringify(data.optional)}</div>
) : (
<div>Optional data unavailable</div>
)}
<div>Status: {data.status}</div>
</div>
);
}Graceful Degradation:
Error Recovery:
Install with Tessl CLI
npx tessl i tessl/npm-recoil