As someone whose first development experience was in web development with jQuery, then transitioned to Angular (beta) and then React, I certainly experienced a fair share of state management nightmare. From the simple and innocuous 'two way data-binding' to applying Functional Programming concepts with libraries like Redux, state management has come a long way. In this article, I would explore the implementation of state management in the Ajaib DeX application. Let's start with the what and why of state management.
What & Why
It is very hard and unusual to have an application or program without some kind of state. In fact, as program complexity grows, the state the program need to manage tends to grow too. When there are multitude of states and interactions going on in a program, there should be some way or method to organize the madness. We need to manage this complexity in order to reason, code and interact with the program. The way we manage this is by state management.
As is the usual with managing complexity, we manage state by creating constraints and structure around it. Therefore, most state management approaches and libraries introduces constructs and structures that serves as constraint to reading and mutating the state. With the popular library Redux, we have actions, reducers and the concept of immutability as our constraints. With the library MobX, we have the similar constructs of state, actions and derivations.
Of course, each library has their own design approach and philosophy. While Redux may be suited for large teams operating in a strict manner, the design behind Redux may not suitable for smaller teams that do not need the verbosity Redux offers (and the guarantees that comes with it). For now, I will not discuss much about the merits behind differing approaches other than that it is a simply a case of 'the right tool for the right job'. Therefore, let's dive straight through Ajaib Dex, its state management, and the motivation underlying its implementation.
Ajaib Dex and State Management
Ajaib DeX is a React (Native) mobile application that serves as a 'hub' for decentralized finance with functionalities for holding a 'crypto' wallet, sending transactions and trading through DeFi platforms. While the design may appear simple, the state involved in the applications are quite numerous and complex in nature.
In terms of state, Ajaib DeX needs to hold an 'application' state consisting of the 'crypto' wallet, cached transaction data, cached financial data (to minimize API calls), and many other related states. Of course, this is in addition to normal 'state' in the form of forms and inputs. To manage this, we used a 'two-tiered' approach to state management (available due to the convenience of React).
'Two-tiered': React and zustand
For simple usecases and state like controlling a form input, it is completely fine and adequate to use built-in react utilities such as the useState
hooks or React Context to manage state. For more complex usecases and global
states, we instead use a library called zustand
that provides simple but solid state managements.
import create from 'zustand'
const useStore = create(set => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}))
function BearCounter() {
const bears = useStore(state => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useStore(state => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
Why zustand?
- Simple and un-opinionated
- Makes hooks the primary means of consuming state
- Doesn't wrap your app in context providers
- Can inform components transiently (without causing render)
- Less boilerplate
- Renders components only on changes
- Centralized, action-based state management
Implementation
As an example of a 'simple' state and usecase, below is the code for a dropdown input for swapping tokens.
const SwapScreenInput = ({ amountSwapInto = "0", setTrade, isLoading = false }) => {
const [swapTokens, setSwapTokens] = useState<{
from: TokenSelectionInfo;
to: TokenSelectionInfo;
}>({
from: TOKEN_SELECTIONS[0]!,
to: TOKEN_SELECTIONS[1]!,
});
const [amountSwapFrom, setAmountSwapFrom] = useState("0");
const onDebouncedAmountChange = useDebouncedCallback((value: string) => {
setAmountSwapFrom(value);
}, 500);
useEffect(() => {
if (amountSwapFrom != "0") {
setTrade(amountSwapFrom, swapTokens);
}
}, [amountSwapFrom, swapTokens]);
return (
<VStack space="2" alignItems="center">
<SwapTokenInput
token={swapTokens.from}
onTokenChange={(tokenSelection) => setSwapTokens({ ...swapTokens, from: tokenSelection })}
onAmountChange={onDebouncedAmountChange}
testID="swap-from"
/>
{isLoading ? (
<Spinner color="blue.500" mb="1" size="sm" />
) : (
<Icon mb="1" as={<MaterialCommunityIcons name="arrow-down" />} color="black" size="md" />
)}
<SwapTokenInput
isDisabled={true}
token={swapTokens.to}
onTokenChange={(tokenSelection) => setSwapTokens({ ...swapTokens, to: tokenSelection })}
amount={amountSwapInto}
testID="swap-into"
/>
</VStack>
);
};
As seen above, a single useState
hook is able to encapsulate the whole state of the component, even also accomodating more complex usecase of debouncing the values. However, the state of the component needs to passed on to a 'parent' or 'handler' as is the case inside the useEffect
hooks above. In more complex applications, these kinds of interactions is more suited to be abstracted away by a state management library. Do note that the useState
hooks may still remain (and not managed by another library) as the data is 'scoped' to the component only.
Below is a short excerpt for a zustand
store for managing global wallet state. We can see different methods/actions for mutating state and also the different state available globally (wallet
, provider
, etc.)
export type CryptoStoreState = {
wallet: ethers.Wallet | null;
provider: ethers.providers.JsonRpcProvider;
onboarding: OnboardingData;
promoteWallet: () => Promise<void>;
loadDefaultWallet: (password: string) => Promise<void>;
createTemporaryData: (password: string) => void;
lastUpdatedInvestment: number;
getInvestment: (string) => Promise<number>;
setInvestmentData: (CoinInvestmentResponse) => void;
seedInvestmentData: () => Promise<boolean>;
getTotalInvestment: () => Promise<number>;
investmentData: CoinInvestmentResponse;
};
export const cryptoStore = create<CryptoStoreState>((set, get) => ({
wallet: null,
provider: new ethers.providers.JsonRpcProvider(RPC_URL),
onboarding: {},
promoteWallet: async () => {
const { temporaryWallet: wallet, temporaryWalletPassword: password } = get().onboarding;
if (wallet && password) {
set((state) => ({ ...state, wallet: wallet.connect(get().provider) }));
await storeDefaultWallet(wallet, password); // perhaps also consider making this a background task?
}
},
loadDefaultWallet: async (password) => {
const wallet = await loadDefaultWallet(password);
set((state) => ({ ...state, wallet: wallet.connect(get().provider) }));
},
createTemporaryData: (password: string) => {
const onboarding = {
temporaryWallet: ethers.Wallet.createRandom(),
temporaryWalletPassword: password,
};
set((state) => ({ ...state, onboarding }));
},
}));
To use the store, we create the following hooks to integrate with our React application:
import { connectorFromStore } from "@lib/crypto/connector";
import { cryptoStore } from "@stores/crypto-store";
import create from "zustand";
export const useCryptoStore = create(cryptoStore);
export const useConnector = () => connectorFromStore(useCryptoStore());
export const useOnboardingStore = () => useCryptoStore((state) => state.onboarding);
export const useWallet = () => useCryptoStore((state) => state.wallet);
export const useInvestment = () => useCryptoStore().getInvestment;
export const useTotalInvestment = () => useCryptoStore().getTotalInvestment;
The hooks can be used in any React component. An example is as follows:
const PortfolioHeader = () => {
const wallet = useWallet();
const { data } = useConditionalWalletAPI(wallet?.address, "returns");
const [holdings, setHoldings] = useState<number>(-1);
const getTotalInvestment = useTotalInvestment();
const isFocused = useIsFocused();
useEffect(() => {
const fetchInvestments = async () => {
await getTotalInvestment().then((holdings) => {
setHoldings(holdings);
});
};
if (isFocused) {
void fetchInvestments();
} else setHoldings(-1);
}, [isFocused]);
const returns = data?.data?.returns ?? "-";
const percentage = holdings > 0 ? (returns / holdings).toFixed(2) : 0;
return ();
}