Last active
November 8, 2018 01:21
-
-
Save nealeu/23ee4919fab5ad0a776d1d741bb556a5 to your computer and use it in GitHub Desktop.
Typescript typings for react-async
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare module "react-async" { | |
import {ComponentType, ReactNode} from "react"; | |
import * as React from "react"; | |
export interface LoadingProps<V> { | |
/** Show only on initial load (data is undefined) */ | |
initial?: boolean; | |
/** Function (passing state) or React node */ | |
children: ReactNode | ((state: State<V>) => ReactNode); | |
} | |
export interface PendingProps<V> { | |
/** Show old data while loading */ | |
persist?: boolean; | |
children: ReactNode | ((data: V) => ReactNode); | |
} | |
export interface RejectedProps<V> { | |
/** Show old error while loading */ | |
persist?: boolean; | |
/** Function (passing error and state) or React node */ | |
children: ReactNode | ((error: Error, state: State<V>) => ReactNode); | |
} | |
/** Can extend this for specifying a functional component | |
* function MyThingWithDefaultLoadAndError(props: {param:number} & ResolvedProps<V>) | |
*/ | |
export interface ResolvedProps<V> { | |
/** Show old data while loading */ | |
persist?: boolean; | |
/** Function (passing data and state) or React node */ | |
children: ReactNode | ((data: V, state: State<V>) => ReactNode); | |
} | |
type Resolved<T> = ComponentType<ResolvedProps<T>>; | |
/** A type to have args for promiseFn also be part of component props */ | |
type AsyncProps<V, ARGS> = Props<V, ARGS> & ARGS; | |
interface Props<V, ARGS> { | |
initialValue?: V | Error; | |
promiseFn?: (props: AsyncProps<V, ARGS>) => Promise<V>; | |
deferFn?: (...args: any[]) => Promise<V>; | |
onResolve?: (args) => unknown; | |
onReject?: (error: Error) => unknown; | |
watch?: any; // compared using !== to see if this has changed to determine if promiseFn should be re-evaluated | |
children?: ReactNode | Element | Element[] | ((value: State<V>) => ReactNode); | |
} | |
interface State<V> { | |
initialValue: V | Error; | |
data?: V; | |
error?: Error; | |
isLoading: boolean; | |
startedAt?: Date; | |
finishedAt?: Date; | |
cancel: () => void; | |
run: () => void; | |
reload: () => void; | |
setData(data: V, callback: () => any): V; | |
setError(error: Error, callback: () => any): Error; | |
} | |
export type AsyncState<V> = State<V>; | |
// Write the interface of the constructor | |
export interface AsyncClass<V = any, A = any> extends React.ComponentClass<AsyncProps<V, A>> { | |
Pending: ComponentType<PendingProps<V>>; | |
Loading: ComponentType<LoadingProps<V>>; | |
Resolved: Resolved<V> & AsyncProps<V, A>; | |
Rejected: ComponentType<RejectedProps<V>>; | |
} | |
/** | |
* e.g. | |
* const MyAsyncClass: AsyncClass<MyData> = createInstance(); | |
* const Element: AsyncElement<MyData> = <MyAsyncClass props... />; | |
*/ | |
export interface AsyncElement<V, A> extends React.ReactElement<AsyncProps<V, A>> { | |
} | |
/** Creates a new Class instance with it's own private context for linking together | |
* Async, Loading, Resolved, Rejected via Context API. | |
* See https://stackoverflow.com/a/31815634/1998186 if we have issues of Class vs instance vs Element | |
*/ | |
export function createInstance<V, A>(defaultProps?: AsyncProps<V, A>): AsyncClass<V, A>; | |
export const Async: AsyncClass; | |
export default Async; // Not sure if this or one above is right | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react" | |
import {ReactNode} from "react"; | |
const isFunction = arg => typeof arg === "function"; | |
interface Props<V> { | |
initialValue?: V | Error; | |
promiseFn?: (props: Props<V>) => Promise<V>; | |
deferFn?: (...args: any[]) => Promise<V>; | |
onResolve?: (args) => unknown; | |
onReject?: (error: Error) => unknown; | |
watch?: any; // compared using !== to see if this has changed to determine if promiseFn should be re-evaluated | |
children?: (value: State<V>) => ReactNode; | |
} | |
interface State<V> { | |
initialValue: V | Error; | |
data?: V; | |
error?: Error; | |
isLoading: boolean; | |
startedAt?: Date; | |
finishedAt?: Date; | |
cancel: () => void; | |
run: () => void; | |
reload: () => void; | |
setData(data: V, callback: () => any): V; | |
setError(error: Error, callback: () => any): Error; | |
} | |
/** | |
* createInstance allows you to create instances of Async that are bound to a specific promise. | |
* A unique instance also uses its own React context for better nesting capability. | |
*/ | |
export function createInstance<I>(defaultProps: Props<any> = {}): React.ComponentClass<Props<I>> { | |
const { Consumer, Provider } = React.createContext({}); | |
class Async<V> extends React.Component<Props<V>, State<V>> { | |
private mounted: boolean; | |
private counter: number; | |
private args: any[]; | |
constructor(props: Props<V>) { | |
super(props); | |
const promiseFn = props.promiseFn || defaultProps.promiseFn; | |
const initialValue = props.initialValue || defaultProps.initialValue; | |
const initialError = initialValue instanceof Error ? initialValue : undefined; | |
const initialData = initialError ? undefined : initialValue; | |
this.mounted = false; | |
this.counter = 0; | |
this.args = []; | |
this.state = { | |
initialValue, | |
data: initialData, | |
error: initialError, | |
isLoading: !initialValue && isFunction(promiseFn), | |
startedAt: undefined, | |
finishedAt: initialValue ? new Date() : undefined, | |
cancel: this.cancel, | |
run: this.run, | |
reload: () => { | |
this.load(); | |
this.run(...this.args) | |
}, | |
setData: this.setData, | |
setError: this.setError | |
} | |
} | |
componentDidMount() { | |
this.mounted = true; | |
this.state.initialValue || this.load() | |
} | |
componentDidUpdate(prevProps) { | |
if (prevProps.watch !== this.props.watch) this.load(); | |
if (prevProps.promiseFn !== this.props.promiseFn) { | |
this.cancel(); | |
if (this.props.promiseFn) this.load() | |
} | |
} | |
componentWillUnmount() { | |
this.cancel(); | |
this.mounted = false | |
} | |
load = () => { | |
const promiseFn = this.props.promiseFn || defaultProps.promiseFn; | |
if (!promiseFn) return; | |
this.counter++; | |
this.setState({isLoading: true, startedAt: new Date(), finishedAt: undefined}); | |
return promiseFn(this.props).then(this.onResolve(this.counter), this.onReject(this.counter)) | |
}; | |
run = (...args) => { | |
const deferFn = this.props.deferFn || defaultProps.deferFn; | |
if (!deferFn) return; | |
this.counter++; | |
this.args = args; | |
this.setState({isLoading: true, startedAt: new Date(), finishedAt: undefined}); | |
return deferFn(...args, this.props).then(this.onResolve(this.counter), this.onReject(this.counter)) | |
}; | |
cancel = () => { | |
this.counter++; | |
this.setState({isLoading: false, startedAt: undefined}) | |
}; | |
onResolve = counter => data => { | |
if (this.mounted && this.counter === counter) { | |
const onResolve = this.props.onResolve || defaultProps.onResolve; | |
this.setData(data, () => onResolve && onResolve(data)) | |
} | |
return data | |
}; | |
onReject = counter => error => { | |
if (this.mounted && this.counter === counter) { | |
const onReject = this.props.onReject || defaultProps.onReject; | |
this.setError(error, () => onReject && onReject(error)) | |
} | |
return error | |
}; | |
setData = (data, callback) => { | |
this.setState({data, error: undefined, isLoading: false, finishedAt: new Date()}, callback); | |
return data | |
}; | |
setError = (error, callback) => { | |
this.setState({error, isLoading: false, finishedAt: new Date()}, callback); | |
return error | |
}; | |
render() { | |
const {children} = this.props; | |
if (isFunction(children)) { | |
return <Provider value={this.state}>{children(this.state)}</Provider> | |
} | |
if (children !== undefined && children !== null) { | |
return <Provider value={this.state}>{children}</Provider> | |
} | |
return null | |
} | |
/** | |
* Renders only when deferred promise is pending (not yet run). | |
* | |
* @prop {boolean} persist Show until we have data, even while loading or when an error occurred | |
* @prop {Function|Node} children Function (passing state) or React node | |
*/ | |
static Pending = ({children, persist}) => ( | |
<Consumer> | |
{(state: State<I>) => { | |
if (state.data !== undefined) return null; | |
if (!persist && state.isLoading) return null; | |
if (!persist && state.error !== undefined) return null; | |
return isFunction(children) ? children(state) : children || null | |
}} | |
</Consumer> | |
); | |
/** | |
* Renders only while loading. | |
* | |
* @prop {boolean} initial Show only on initial load (data is undefined) | |
* @prop {Function|Node} children Function (passing state) or React node | |
*/ | |
static Loading = ({children, initial}) => ( | |
<Consumer> | |
{(state: State<I>) => { | |
if (!state.isLoading) return null; | |
if (initial && state.data !== undefined) return null; | |
return isFunction(children) ? children(state) : children || null | |
}} | |
</Consumer> | |
); | |
/** | |
* Renders only when promise is resolved. | |
* | |
* @prop {boolean} persist Show old data while loading | |
* @prop {Function|Node} children Function (passing data and state) or React node | |
*/ | |
static Resolved = ({children, persist}) => ( | |
<Consumer> | |
{(state: State<I>) => { | |
if (state.data === undefined) return null; | |
if (!persist && state.isLoading) return null; | |
if (!persist && state.error !== undefined) return null; | |
return isFunction(children) ? children(state.data, state) : children || null | |
}} | |
</Consumer> | |
); | |
/** | |
* Renders only when promise is rejected. | |
* | |
* @prop {boolean} persist Show old error while loading | |
* @prop {Function|Node} children Function (passing error and state) or React node | |
*/ | |
static Rejected = ({children, persist}) => ( | |
<Consumer> | |
{(state: State<I>) => { | |
if (state.error === undefined) return null; | |
if (state.isLoading && !persist) return null; | |
return isFunction(children) ? children(state.error, state) : children || null | |
}} | |
</Consumer> | |
); | |
} | |
return Async | |
} | |
export default createInstance(); |
If anyone is willing to pick up the .d.ts and add it as @types/react-async via DefinitelyTyped, then do go ahead.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I've started adding types to react-async as an effort to understand how I might add ambient types for this. It's certainly an interesting pattern to apply .d.ts to.