Home

Making a simple, reusable async context in React

About a 11 minute read

Written by Kyle Hawk on Oct 4, 2025

#coding#react

I’m in the middle of rewriting an application at work in React. It’s been fun and a learning experience since I haven’t used React much. My local admin dashboard for random anime is in React, but I wrote that a couple years ago, hated every second of it and generally try to avoid having to update it.

My recent experience has been much, much better. There’s a lot to learn and a million ways to accomplish a single goal. My goal this time? Sharing global data.

Our application isn’t really big enough to justify bringing in a fancy state management library, so I decided to use the Context API. After writing a couple of contexts, I realized there was a lot of similarities. So much so that I decided to write a generic context that could be used for both.

Let’s dive in!

I’m not a React expert, so take my example with a grain of salt. It works for what I need, but there may be better ways to do it.

Basic setup

Alright. So what’s this thing going to look like? Let’s start with the context’s interface.

TYPESCRIPT

export interface AsyncDataContext<R> {
    data: R | undefined;
    isLoading: boolean;
    error: Error | null;
    getData: (forceRefresh?: boolean) => Promise<R>;
    setData: (newData: R) => Promise<void>;
}

We have data which will.. hold our data. An error variable in case something goes wrong. isLoading to keep track of when we’re fetching data. Then two functions, a getter and setter essentially.

Why R instead of T? You’ll see in a minute.

TYPESCRIPT

export function createAsyncDataContext<T, R = T>(
    fetchData: (forceRefresh?: boolean) => Promise<T>,
    transform?: (data: T) => Promise<R>,
) {
    let cachedData: R | undefined;
    let currentFetchPromise: Promise<R> | null = null;

    async function getData(forceRefresh = false): Promise<R> {}

    const Context = createContext<AsyncDataContext<R> | undefined>(undefined);

    function Provider({ children }: { children: ReactNode }) {}

    function useAsyncData() {}

    return { Provider, useAsyncData, getData };
}

Alright, here’s our basic structure.

Our function accepts two parameters, fetchData and an optional transform. The transform is the reason for the R generic type. T represents the data that fetchData returns, and R represents the data that transform returns.

We have two variables for “caching”. One holds the returned, optionally transformed data and the other holds the promise for fetching the data. We also have our context variable that we will use with the useContext hook.

The getData function holds the logic for.. getting the data. You’ll notice it also has a parameter, forceRefresh. This param can be helpful for flushing the cache. The Provider function is what actually returns the TSX for the context provider and it’s values. Finally, useAsyncData is the hook we create for creating the context.

At the end, we return it all. Sweet.

Provider and Hook

Let’s look at the details for the provider and custom hook.

TSX

function Provider({ children }: { children: ReactNode }) {
    const [data, setData] = useState<R | undefined>(cachedData);
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);

    const getDataInternal = async (forceRefresh = false): Promise<R> => {
        setIsLoading(true);
        setError(null);
        try {
            const result = await getData(forceRefresh);
            setData(result);
            return result;
        } catch (err) {
            const errorObj = err instanceof Error ? err : new Error(String(err));
            setError(errorObj);
            throw errorObj;
        } finally {
            setIsLoading(false);
        }
    };

    const value: AsyncDataContext<R> = useMemo(
        () => ({
            data,
            isLoading,
            error,
            getData: getDataInternal,
            setData: updateData,
        }),
        [data, isLoading, error]
    );

    return <Context.Provider value={value}>{children}</Context.Provider>;
}

function useAsyncData() {
    const context = useContext(Context);
    if (!context) {
        throw new Error("useAsyncData must be used within its Provider");
    }
    return context;
}

It’s pretty simple. Our custom hook it’s just a few lines. We pass in our created context to useContext, make sure it’s successful, and then return it.

The Provider function does the bulk of our work. We keep track of data, error, and isLoading; updating those variables as it makes sense. We await our getData function, which we’ll look at next.

We’ll either get a result, or throw an error. The value we pass into our context provider’s value is wrapped in useMemo to try and limit re-evals. We return everything that’s needed and give it dependencies to look out for for changes.

Cool, so now the magic is passed off to the getData function. Let’s look at that.

TYPESCRIPT

async function getData(forceRefresh = false): Promise<R> {
    if (!forceRefresh && cachedData) return cachedData;
    if (currentFetchPromise && !forceRefresh) return currentFetchPromise;

    const fetchPromise = (async () => {
        try {
            const result = await fetchData(forceRefresh);
            const transformed = transform
                ? await transform(result)
                : (result as unknown as R);
            cachedData = transformed;
            return transformed;
        } finally {
            currentFetchPromise = null;
        }
    })();

    currentFetchPromise = fetchPromise;
    return fetchPromise;
}

This function is where we need to be careful so we don’t constantly re-fetch data. First up, if it’s not a force refresh and we got cached data, amazing, return the cache. Next, if two components happen to call this at the same time and the promise isn’t resolved yet, we return the promise to both of them so they both can wait for it.

The actual fetch pretty simple. Call our passed in method for fetching data. Optionally, passed it to a transform function if it exists. If we’re all good by that point, we store the cached data. We also wrap this fetch promise in a variable and store that so we can return it like cached data if it’s not done yet.

Simple, but effective. And, all in one place.

Implementation

The implementation is pretty dang simple.

TYPESCRIPT

export const {
    Provider: YourProviderName,
    useAsyncData: useYourDataName,
    getData: getYourDataName,
} = createAsyncDataContext<ResultType, TransformedType[]>(yourFetchFunction, yourTransformFunction);

That’s it. We give our generically named return variables valuable names, pass in our types and fetch/transform functions. If you don’t need a transform, don’t pass that type and function. Easy.

Once we have this established, we just use it like any other context. In your main, or whatever component or page you want the context to exist, you import YourProviderName and wrap the children in it.

For the children that use it, you import useYourDataName.

Why getData and getDataInternal?

You may have noticed there are technically two get data functions. Though, getDataInternal just references getData. But, why?

The answer is in the implementation, at least for me. The getDataInternal function is exposed via getData within the custom hook. Hooks have special rules on when you can use them.

So.. what if I wanted to call getData within, say, a loader function for a page? That’s where the exported getData function comes in. That is the one that you can call in any scenario where you can’t use a hook.

Two calls - one can be used anywhere, the other within the hook - both do the same thing and share the cache. So you can call getData in your loader and then that data will be cached for your hook within the page or component.

I think it’s a neat solution.

That’s all

There you have it. My reusable context for async data that I’m pretty proud of. It’s worked really well for me and my use cases. I hope it’s useful for others.

Just a note - if I did something dumb, feel free to let me know. I’m always down to learn from my mistakes.

Thanks for reading!