rtk-thunkease
v0.3.0
Published
Redux Toolkit createSlice wrapper that generates loading/error/data state and the full pending/rejected/fulfilled triad for every async thunk you hand it.
Maintainers
Readme
rtk-thunkease
Redux Toolkit's createSlice, minus the async boilerplate.
Hand it a record of createAsyncThunks and it generates, for each one, a
{ data, isLoading, error, status, isFetchedOnce } slot, the full
pending/rejected/fulfilled triad, and reset / invalidate actions — all fully
typed from the thunk's payload.
npm i rtk-thunkeasePeer dependency: @reduxjs/toolkit v2. Zero runtime dependencies.
Live demo of all three modes: https://srcgrp.github.io/rtk-thunkease/ (source).
Before / after
// Plain Redux Toolkit
const slice = createSlice({
name: 'user',
initialState: {
profile: { data: undefined, isLoading: false, error: undefined, status: 'Idle', isFetchedOnce: false }
},
reducers: {
resetProfile(state) {
state.profile = { data: undefined, isLoading: false, error: undefined, status: 'Idle', isFetchedOnce: false };
}
},
extraReducers: (builder) => {
builder
.addCase(getProfile.pending, (state) => {
state.profile.status = 'Loading';
state.profile.isLoading = true;
})
.addCase(getProfile.rejected, (state, action) => {
state.profile.status = 'Failed';
state.profile.isLoading = false;
state.profile.error = action.error;
})
.addCase(getProfile.fulfilled, (state, action) => {
state.profile.status = 'Succeeded';
state.profile.isLoading = false;
state.profile.error = undefined;
state.profile.isFetchedOnce = true;
state.profile.data = action.payload;
});
}
});// rtk-thunkease
const slice = thunkEase({
name: 'user',
thunks: { getProfile }
});Both give you state.user.getProfile.isLoading and a reset action. The second
one scales to twenty thunks without growing.
The four modes
The thunk key's suffix picks how its state is shaped. The suffix is stripped to
get the state key, so getOrders$items fills state.getOrders.
| Thunk key | State shape | On fulfilled |
| --- | --- | --- |
| getProfile | ApiState<T> | data replaced |
| getOrders$items | ApiState<T> | payload.items appended onto data.items |
| getAddress_addressId | HashMapApiState<T> | bucket at meta.arg.addressId replaced |
| getLandingRow_ | HashMapApiState<T> | bucket at the whole meta.arg replaced |
const slice = thunkEase({
name: 'user',
thunks: {
getProfile,
getOrders$items: getOrders,
getAddress_addressId: getAddress,
getLandingRow_: getLandingRow
}
});
// state.user.getProfile -> ApiState<Profile>
// state.user.getOrders.data.items -> every page fetched so far, concatenated
// state.user.getAddress['a-1'] -> ApiState<Address> | undefined
// state.user.getLandingRow['row-7'] -> ApiState<Row> | undefined_ is checked before $, so a key containing both is treated as a hash map.
_ and $ are therefore reserved — a thunk key cannot use them for anything
else.
Paginated mode appends blindly
getOrders$items concatenates payload.items onto whatever is already in
data.items. There is no page tracking and no de-duplication, so dispatching the
same page twice stores its rows twice. Dispatch resetGetOrdersPage() before
re-fetching from the start, or de-duplicate in your selector.
Generated actions
Every thunk gets two actions; paginated thunks get a third.
| Action | Effect |
| --- | --- |
| reset<Name>() | slot back to its initial idle value |
| invalidate<Name>() | status back to 'Idle', data and isFetchedOnce kept |
| reset<Name>Page() | paginated only: empties the appended array, keeps the rest of data |
For hash-map thunks both take an optional key:
dispatch(slice.actions.resetGetAddress('a-1')); // drop one bucket
dispatch(slice.actions.resetGetAddress()); // drop every bucket
dispatch(slice.actions.invalidateGetAddress()); // every bucket back to Idle, data keptinvalidate exists for the common "fetch once when it scrolls into view" guard:
useEffect(() => {
if (inView && status === 'Idle') dispatch(getProfile());
}, [inView, status]);
// later, to force a refetch without flashing empty UI:
dispatch(slice.actions.invalidateGetProfile());A name collision between one of these and your own reducer throws at slice construction rather than silently overwriting.
Status values
type AsyncThunkStatus = 'Idle' | 'Loading' | 'Failed' | 'Succeeded';Compare against the literals or use the exported constant:
import { AsyncThunkStatuses } from 'rtk-thunkease';
if (status === AsyncThunkStatuses.SUCCEEDED) { /* ... */ }data starts as {} rather than undefined, so state.getProfile.data.items
does not throw before the first fetch. It is still typed T | undefined, because
after the first fulfilled action it holds a real payload.
React hooks
The rtk-thunkease/react entry point adds two hooks. They need react and
react-redux; the core entry point does not.
import { useApiState, useHashApiState } from 'rtk-thunkease/react';
function Profile() {
const { data, isLoading, isIdle, isError } = useApiState((s: RootState) => s.user.getProfile);
// ...
}
function Address({ addressId }: { addressId: string }) {
const { data, isLoading } = useHashApiState((s: RootState) => s.user.getAddress, addressId);
// ...
}Both add isIdle, isSuccess and isError to the slot, and read a missing slot
or an unfetched bucket as idle — so there is nothing to guard before
destructuring.
Pin the state type once
The (s: RootState) annotation above is not decoration. TypeScript has nothing
to infer the store's state from, so an unannotated (s) => s.user.getProfile
leaves s as unknown and collapses data to {}.
Rather than repeat it at every call site, pin it once — same shape as
react-redux's useSelector.withTypes:
// store/hooks.ts
import {
useApiState as useApiStateBase,
useHashApiState as useHashApiStateBase
} from 'rtk-thunkease/react';
export const useApiState = useApiStateBase.withTypes<RootState>();
export const useHashApiState = useHashApiStateBase.withTypes<RootState>();// s is RootState, data is Profile | undefined
const { data } = useApiState((s) => s.user.getProfile);The payload type still comes off the slot, so nothing else needs spelling out.
Options
thunkEase({
name, // slice name, as in createSlice
thunks, // record of createAsyncThunk results, keyed by state slot
initialState, // optional extra state alongside the generated slots
reducers, // optional case reducers of your own
extraReducers // optional builder callback, runs after the generated cases
});initialState is merged underneath the generated slots, so you can seed
data for a slot and still get its skeleton:
thunkEase({
name: 'user',
initialState: { sidebarOpen: false, getProfile: { data: cachedProfile } },
thunks: { getProfile }
});extraReducers runs after the generated cases. Adding a case for a thunk that
is already in thunks throws, because Redux Toolkit rejects two reducers for one
action type — use it for actions the thunks do not cover, such as
persist/REHYDRATE.
reducers takes the plain (state, action) => void form. The
{ reducer, prepare } form is not typed here; reach for extraReducers or plain
createSlice if you need a prepare callback.
Exports
| Export | |
| --- | --- |
| thunkEase | the slice factory |
| createSliceWithThunks | alias of thunkEase |
| AsyncThunkStatuses | the four status strings |
| createInitialApiState | a fresh idle ApiState, useful as a selector fallback |
| ApiState, HashMapApiState, AsyncThunkStatus | state types |
| ConvertedThunks, PureStateName, GetAsyncThunkReturnType | type helpers |
| ThunkEaseOptions, ThunkEaseSlice, GeneratedCaseReducers | signature types |
| useApiState, useHashApiState, ApiStateView | from rtk-thunkease/react |
| UseApiState, UseHashApiState, TypedUseApiState, TypedUseHashApiState | hook signature types, rtk-thunkease/react |
License
MIT
