npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-thunkease

Peer 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 kept

invalidate 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