The "Good Enough" React Provider
It's better than how you do it
React context - the bane of many programmers, a monster done wrong and oftentimes bamboozling even when done right. Today, following SoC principles, I will show you a highly opinionated way of tackling this state management tool in typescript (an incredible language - also an opinion).
This provider pattern consists of 5 parts: the provider itself, the network calls used by the provider, the state of the provider, the functions for managing the relationship between the provider’s state and network calls, and the provider’s types.
/exampleProvider
-/provider.tsx
-/network.ts
-/state.ts
-/functions.ts
-/types.tsNow that we have our set-up complete, let’s dive into the typing. A good provider needs to keep track of two things - data and the loading state for that data. While we’re here, we also want an interface to change the loading states and the data states, so we will type something called a state controller. The final type we will want located in this file is going to be for your provider functions, which we will dive into later.
types.ts
import { ExampleDTO } from "@/app/network/types/outputDtos"
import { Dispatch, SetStateAction } from "react";
export type ExampleProviderState = {
exampleData: ExampleDTO | undefined
};
export type ExampleProviderSet = {
setExampleData: Dispatch<
SetStateAction<ExampleDTO | undefined>
>;
}
export type ExampleProviderLoading = {
loading: boolean;
exampleDataLoading: boolean;
};
export type ExampleProviderSetLoading = {
setExampleDataLoading: Dispatch<SetStateAction<boolean>>;
}
export type ExampleProviderStateController = {
// Data state
state: ExampleProviderState;
set: ExampleProviderSet;
// Loading state
loading: ExampleProviderLoading;
setLoading: ExampleProviderSetLoading;
};
export type ExampleProviderFunctions = {
getExampleData: () => Promise<void>;
}Now let’s get into defining our network calls, all the network requests that this provider is allowed to make. For this we’ll move over to the network.ts file.
If you're like me, your frontend repo has a controller that maps one to one with every backend service so you know exactly what functionality you have available to you at a glance. If you aren’t… pretend you have a series of functions within a composed object called “exampleController“ where the functions are the network calls you can make. With that in mind, this will be your network file.
network.ts
import { exampleController } from "@/app/lib/server/controllers/example/controller";
import { params } from "@/app/lib/server/controllers/example/params";
/**
* exampleController: the composed object I mentioned above, it takes
* arguments in case you need to pass in an auth token or something
* similar.
*
* params: a composed object containing the type of each params for every
* defined network call.
*/
export const network = {
getExampleData: ({}: params.GetExampleData) => {
return exampleController().getExampleData();
},
}
/**
* If route required auth
*/
export const network = {
getExampleData: ({
authToken
}: params.GetExampleData & { authToken: string}) => {
return exampleController({ authToken }).getExampleData();
},
}
/**
* If route required a parameter, say, "id"
*/
export const network = {
getExampleData: ({
id
}: params.GetExampleData) => {
return exampleController().getExampleData({id});
},
}Some may think this is too much abstraction, if you already have a controller defining what network calls you can make, why make a network object for the provider? A provider can use only a subset of the controller’s network calls, and only a subset of those need authorization.
Now let’s get into state, we already defined all the types we’ll be needing along with a controller for mutating the state, so let’s get into what this looks like.state.ts
import { useState } from "react";
import { ExampleDTO } from "@/app/network/types/outputDtos";
import { ExampleProviderStateController } from "./types";
export const useExampleProviderStateController =
(): ExampleProviderStateController => {
// Loading Primitives
const [exampleDataLoading, setExampleDataLoading] = useState(false);
// a loading constant derived from providers loading states,
// generally useful info
const loading = exampleDataLoading // || ...otherLoadingState
// Data (you will likely want to use a persistent version of
// useState for the actual data)
const [exampleData, setExampleData] = useState<ExampleDTO | undefined>()
// return a stateController
return {
state: {
exampleData
},
set: {
setExampleData
},
loading: {
loading,
exampleDataLoading
},
setLoading: {
setExampleDataLoading
}
}
}The state file is pretty simple right? You just define states and return a state controller and then you’re done.
We’ve done all the setup now so let’s start tying things together starting with functions. The functions file will take the state controller that we just defined, along with the network object we made earlier, and create methods for changing the state via network requests.functions.ts
import { useEffect } from "react";
import { network } from "./network";
import { ExampleProviderFunctions, ExampleProviderStateController } from "./types";
export const useExampleProviderFunctions = (
stateController: ExampleProviderStateController
): ExampleProviderFunctions => {
useEffect(() => {
initializeProviderData()
}, [])
const initializeProviderData = async () => {
await getExampleData()
// ...other functions to run on initialization
}
const getExampleData = async() => {
stateController.setLoading.setExampleDataLoading(true)
try {
const newExampleData = await network.getExampleData({})
stateController.set.setExampleData(newExampleData)
} catch(e) {
// ...do error handling
}
stateController.setLoading.setExampleDataLoading(false)
}
return {
getExampleData
}
}Just like that, you have functions that define how network calls interact with loading and state! If you have a bug where loading is being wonky or the state isn’t populating as it should, you know exactly where to look.
The final step in all this is the actual provider file, where we tie everything we’ve just done together in a nice neat provider.provider.ts
import React, { useContext, useMemo } from "react";
import { useExampleProviderStateController } from "./state";
import { useExampleProviderFunctions } from "./functions";
import {
ExampleProviderFunctions,
ExampleProviderLoading,
ExampleProviderState,
ExampleProviderStateController,
} from "./types";
import { createRegisteredContext } from "react-singleton-context";
/**
* Defining what our context has access to, we want our context to be
* able to give us the state, the loading state, and the functions,
* but we do not want to be able to mess with state willy nilly.
* So, we remove set and setLoading from the controller and
* append the functions so we don't have to repeat typing.
* I would have put this in the types file but I wanted this to be
* straightforward.
*/
type ExampleProviderContext = Omit<
ExampleProviderStateController,
"set" | "setLoading"
> & {
functions: ExampleProviderFunctions;
};
const defaultProvider: ExampleProviderContext = {
state: {
exampleData: undefined,
},
loading: {
loading: false,
exampleDataLoading: false,
},
functions: {
getExampleData: async () => {},
},
};
const ExampleRegisteredContext = createRegisteredContext<ExampleProviderContext>(
"example-provider-context",
defaultProvider
);
// export the provider
export const ExampleProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const stateController = useExampleProviderStateController();
const functions = useExampleProviderFunctions(stateController);
// Define providers value (context)
const value = useProviderInterface(
stateController.state,
functions,
stateController.loading
);
// Wrap the children with the context provider
return <ExampleRegisteredContext.Provider value={value}>{children}</ExampleRegisteredContext.Provider>;
};
// composes and updates provider's values
const useProviderInterface = (
state: ExampleProviderState,
functions: ExampleProviderFunctions,
loading: ExampleProviderLoading
): ExampleProviderContext => {
// update providers values on state and loading changes
return useMemo(
() => ({
state,
functions,
loading,
}),
[state, loading.loading]
);
};
export const useExampleContext = () => useContext(ExampleRegisteredContext);Now we have a solid provider, easy to read, easy to debug, and easy to make, you just need to wrap your app with it. There are of course improvements to be made, but I’ll leave that up to you. For now, this is a “good enough” provider pattern.

