@vue-kakuyaku/core
v0.4.3
Published
Async operations toolkit for Vue
Downloads
323
Maintainers
Readme
@vue-kakuyaku/core
Toolkit to handle async operations in Vue.
This is the core library of vue-kakuyaku
project.
Features
- Focus on the best TypeScript support
- Minimal opt-in API
- Suitable for both "fetching data" and "doing side effect" cases
- Utilities:
- Retry-on-error
- Stale-if-error state
- Shorthand watchers for task results
- Non-parametrised and reactively parametrised scopes to model async operations in a more modular way
Installation
npm install @vue-kakuyaku/core
Docs
Atomic Promise State
The core primitive type of the library is PromiseStateAtomic<T>
. It describes all possible states of a Promise at a given time in type-safe and exclusive manner.
PromiseStateAtomic<T>
is a set of mutually exclusive invariants:
type PromiseStateAtomic<T> =
// empty (uninitialised)
| { rejected: null; fulfilled: null; pending: false }
// pending
| { rejected: null; fulfilled: null; pending: true }
// fulfilled
| { rejected: null; fulfilled: { value: T }; pending: false }
// rejected
| { rejected: { reason: unknown }; fulfilled: null; pending: false }
Compared to other libraries that work with promises, PromiseStateAtomic<T>
is type-safe for the following reasons:
- Empty
fulfilled
andrejected
states are easily distinguished. You don't need to guess ifstate.fulfilled === null
means that the promise is not fulfilled yet or if it is resolved withnull
value.state.fulfilled
isnull | { some: T }
, so if there is somestate.fulfilled
, then the promise is fulfilled. Same forstate.rejected
. rejected.reason
is anunknown
, not anError
or unsafeany
, which is how JavaScript works: anything could bethrow
n.
PromiseStateAtomic<T>
is exclusive because at any given moment promise is either pending, rejected, fulfilled or empty, and not a mix of them. Thus, TypeScript narrows types on assertions:
declare const state: PromiseStateAtomic<string>
if (state.pending) {
// No error: TypeScript narrows the types of
// `rejected` and `fulfilled`
const a: null = state.rejected || state.fulfilled
}
Let's proceed to the utilities built around PromiseStateAtomic<T>
.
Basics with usePromise()
This composable returns a basic reactive model over a promise you put into it:
import { computed } from 'vue'
async function getString() {
return '42'
}
const { state, set, clear } = usePromise<string>()
// `state` is a reactive object
// initially it is an empty state
const isPending = computed(() => state.pending)
// passing a `Promise<string>`
set(getString())
// forget the currently tracked promise if there is any
clear()
usePromise<T>()
composable returns:
- Reactive
state
(PromiseStateAtomic<T>
) - The method to set a promise (
set(promise: Promise<T>)
) so its state is reflected instate
- The method to clear (
clear()
) the composable to the initial empty state
Note: if a new promise is set
while the previous one is pending, the result of the previous promise is ignored.
Repetitive action with useTask()
This composable is almost like usePromise()
, but it accepts an async non-parametrised function in it to repeat it over and over again. It is useful when the action is not based on any input parameters (at least within a scope).
const { state, run, clear } = useTask(async () => {
await delay(300)
return 42
})
// this callback does not accept any parameters
run()
The task could be run immediately if the options are passed:
const task1 = useTask(fn, {
immediate: true,
})
// equivalent to
const task2 = useTask(fn)
task2.run()
You might ask: why not accept parameters in run(...args)
and forward them into the async function? TypeScript is not good at extracting parameter types, especially for overloaded functions. This means that if there are reactive parameters, it is better to use scopes.
Watcher shorthands for the state
There are the following shorthands:
wheneverFulfilled
wheneverRejected
wheneverDone
They are just simple wrappers around Vue's watch
.
declare const state: PromiseStateAtomic<{ foo: 'bar' }>
wheneverFulfilled(state, ({ foo }) => {
console.log('Guess what "foo" is:', foo)
})
wheneverRejected(
state,
(reason) => {
console.error('Whoops:', reason)
},
{
// default `watch` options
flush: 'sync',
},
)
wheneverDone(state, (result) => {
if (result.rejected) {
// TS narrowing works here as well
console.error(result.rejected.reason)
} else {
console.log(result.fulfilled.value)
}
})
Each watcher accepts options
which are identical to the options that watch
accepts.
Flatten the state with flattenState()
Sometimes there is a need to reduce verbosity while accessing PromiseStateAtomic<T>
's fulfilled
and rejected
fields:
declare const state: PromiseStateAtomic<{ bar: 'baz' }>
if (state.fulfilled) {
const baz = state.fulfilled.value.bar
}
In this example, there is no need for fulfilled
to be null | { value: { bar: 'baz' } }
in order to distinguish empty fulfilled state from the existing one. It would be enough to use null | { bar: 'baz' }
.
For such a case, the state could be reactively flattened when there is no need for nested fulfilled.value
and rejected.reason
fields:
const flattenedState = flattenState(state)
if (flattenedState.fulfilled) {
const baz = state.fulfilled.bar
}
You can pass mode
argument to control which fields are flattened:
// `all`, `fulfilled` (default) and `rejected` are accepted
flattenState(state, 'all')
Retry on error with useErrorRetry()
This composable watches for the promise's state. If it is rejected
, the composable invokes the callback (assuming that it will trigger the state to refresh) for a given number of times with a given interval.
const { state, run } = useTask(
async () => {
if (Math.random() > 0.5) throw new Error('bad luck')
},
{ immediate: true },
)
useErrorRetry(state, () => run(), {
// default - 5
count: 10,
// default - 5000
interval: 300,
})
Stale if error with useStaleState()
This composable is a tiny and lightweight implementation of stale-while-revalidate pattern. In short, it is about caching and using successful result while the state of async task is revalidating, even with failures.
declare const state: PromiseStateAtomic<string>
const staleState = useStaleState(state)
The atomic state
is converted to the staleState
that is of type PromiseStaleState<T>
:
interface PromiseStaleState<T> {
fulfilled: null | { value: T }
rejected: null | { reason: unknown }
pending: boolean
fresh: boolean
}
When the atomic state becomes fulfilled, the stale state updates its fulfilled
value and removes the last rejection reason if there was any. When the atomic state becomes rejected, the stale state only updates the rejection reason without touching the last fulfilled
value.
PromiseStaleState<T>
type is not exclusive: the stale state might have a fulfilled value, a rejection reason and be pending at the same time. You might think of it as of a PromiseStateAtomic<T>
with memory about its previous executions.
This utility might be useful in simple scenarios. However, it cannot be compared to libraries such as Kong/swrv
or ConsoleTVs/vswr
, which implement SWR pattern in a much more comprehensive way. However, these libraries have their own drawbacks. Thus, @vue-kakuyaku/swr
is planned to be a competitive solution.
Set up an async action in separate scope with useDeferredScope()
When it comes to modelling async actions within a component (or any other reactive scope), it might become a mess if an action's lifetime is not the same as the lifetime of the component. The action might be initialised on one event and be discarded on another. During the component's setup stage, you might need to set up reactive logic around this action, such as timers, retrying, or showing notifications.
Fortunately, Vue provides API for creating your own scopes! With them, you can isolate async actions and their reactive logic within a dedicated scope, which you can set up and dispose at any time. useDeferredScope<T>()
does exactly this.
Check this example to see how it works:
interface Params {
username: string
password: string
}
const {
// it is a `Ref<null | { expose: T }>, where `expose` is
// what is returned from the scope's setup function
scope: loginScope,
setup: loginSetup,
dispose: cleanLogin,
} = useDeferredScope<{
isOk: Ref<boolean>
retry: () => void
// sometimes we want to know the exact params of the last login
params: Params
}>()
function doLogin(params: Params) {
// if the scope is already set up, it will be disposed
loginSetup(() => {
// during this function we can setup any reactive logic and
// be sure that it will be cleared automatically on scope dispose
const { state, run } = useTask(() => httpLogin(params), {
immediate: true,
})
const isOk = computed(() => !!state.fulfilled)
return { isOk, params, retry: run }
})
}
const isLoginOk = computed(
() => loginScope.value?.expose.isOk ?? false,
)
function retryLogin() {
loginScope.value?.expose.retry()
}
We follow existing Vue semantics around the
expose
keyword.
This utility is a bit low-level. One of the very common cases when this utility is not very useful is when you need to set up an async action based on reactive parameters. The next section provides a solution for this scenario.
Reactively parametrised scope with useParamScope()
Consider the following common scenario: you have a reactive userId
and you need to fetch user data for the given ID. Additionally, you might need to set up some reactive logic around it.
Here is how it might look when you use useParamScope()
:
const userId = ref(1)
const scope = useParamScope(
// reactive key - a ref or a getter
userId,
// setup function, that accepts the resolved key for the scope
(staticUserId) => {
const { state, run } = useTask(
() => fetch(`/users/${staticUserId}`),
{ immediate: true },
)
useErrorRetry(state, run)
const staleState = useStaleState(state)
return staleState
},
)
const userData = computed(() => scope.value.expose.fulfilled?.value)
Whenever a reactive key is changed, the existing scope is disposed, and the new one is set up. The reactive keys should be primitive keys: number | string | symbol | boolean
.
The key might be composed, i.e. have a non-primitive payload associated with the primitive value:
const params = reactive({ length: 5, width: 1 })
const scope = useParamScope(
() => ({
// `key` is the source of truth for tracking changes
// if `payload` is changed on re-computation, but `key` is not,
// the change will be ignored
key: `${params.length}-${params.width}`,
payload: { ...params },
}),
({ payload: params }) => {
// ...
},
)
The key might be boolean
if you only need to toggle the scope's existence:
const enabled = ref(false)
useParamScope(enabled, () => {
useIntervalFn(() => console.log('I am alive!!!'), 300)
})
Miscellaneous
delay(ms: number)
:
await delay(500)
deferred()
:
const promise = deferred<number>()
promise.then((x) => {
console.log('Number:', x)
})
promise.resolve(42)
Why the name?
"Kakuyaku" (確約) means "Promise" in Japanese.