Frontend engineering is a minefield when it comes to learning about effective patterns. As part of my personal study, I'm reading real open-source codebases to understand the patterns in the wild. Today I've read the codebase for a decentralised finance (DeFi) dApp called MakerDAO.
In my own personal project, T1 Gym, I just implemented React Router so I could link various day's worth of diabetic data. I'm interested in the areas of asynchronous data fetching, state management patterns (redux, hooks, contexts), routing frameworks, the different ways to check if data is loading and show a loading view. Overall, this is part of my larger goal of building great user experiences from good frontend engineering patterns.
This post is mostly an exploration, and is just verbatim pasted as I've done it.
NB: MakerDAO's engineers are sophisticated, though prioritise a functional programming style and composability. This may appear overengineered at first sight, though I assure you, probably only 20% ;).
Exploration log.
Codebase for frontend: https://sourcegraph.com/github.com/makerdao/mcd-cdp-portal
When you load the Borrow route.
https://oasis.app/borrow/owner/0x912fd21d7a69678227fe6d08c64222db41477ba0
The Borrow route calls the VaultsProvider
, to get the state of viewedAddressVaults
.
If viewedAddressVaults.length === 0
, then the loading screen is rendered.
Where kicks off the loading of this state?
At a top-level, this is what the routes component tree looks like:
const dappProvidersView = async request => {
const {
network = networkNames[defaultNetwork],
testchainId,
backendEnv
} = request.query;
const { viewedAddress } = request.params;
return (
<MakerProvider
network={network}
testchainId={testchainId}
backendEnv={backendEnv}
viewedAddress={viewedAddress}
>
<RouteEffects network={network} />
<TransactionManagerProvider>
<NotificationProvider>
<VaultsProvider viewedAddress={viewedAddress}>
<ToggleProvider>
<ModalProvider modals={modals} templates={templates}>
<SidebarProvider>
<View />
</SidebarProvider>
</ModalProvider>
</ToggleProvider>
</VaultsProvider>
</NotificationProvider>
</TransactionManagerProvider>
</MakerProvider>
);
};
const withDashboardLayout = childMatcher =>
compose(
withView(dappProvidersView),
// ...
export default mount({
// basename ought to be set to '/borrow' and router will construct
// these routes as basename+route
'/': compose(
withView(dappProvidersView),
withView(() => <Borrow />)
),
'/owner/:viewedAddress': withDashboardLayout(
route(request => {
const { viewedAddress } = request.params;
return {
title: 'Overview',
view: <Overview viewedAddress={viewedAddress} />
};
})
),
// ...other routes.
})
So for rendering the /owner/:viewedAddress
route, a series of *Provider
HOC's are loaded first. Let's examine the VaultsProvider
HOC.
function VaultsProvider({ children, viewedAddress }) {
const navigation = useNavigation();
const { account, network } = useMaker();
const { cdpTypesList } = useCdpTypes();
const userProxy = watch.proxyAddress(account?.address);
const viewedAddressProxy = watch.proxyAddress(viewedAddress);
const rawUserVaultsList = watch.userVaultsList(account?.address);
const rawViewedAddressVaultsList = watch.userVaultsList(viewedAddress);
The VaultsProvider
HOC begins pretty plainly with importing some other hooks for navigation and domain-specific logic for MakerDAO smart contracts.
Looking for the state we are examining, viewedAddressVaults
, it appears it is set after a chain of other variables are "filled" first - viewedAddressProxy
, viewedAddressVaultsList
, viewedAddressVaultIds
and then finally viewedAddressVaultsData
.
return (
<VaultsContext.Provider
value={{
userVaults:
rawUserVaultsList !== undefined
? userVaultIds && userVaultsData
? userVaultsData
: []
: userProxy
? undefined
: [],
viewedAddressVaults:
viewedAddressProxy === undefined
? undefined
: viewedAddressProxy === null
? []
: viewedAddressVaultsList === undefined
? undefined
: !viewedAddressVaultIds.length
? []
: viewedAddressVaultsData
}}
>
{children}
</VaultsContext.Provider>
);
Before we dig in further, let's get an understanding of what watch
does. It looks like it's following the observable pattern of reactive state management.
Looking at the source, it's a fairly straightforward control flow:
function useObservable(key, ...args) {
const { maker } = useMaker();
const multicall = maker.service('multicall');
const [value, setValue] = useState(undefined);
useEffect(() => {
if (!maker || !multicall.watcher) return;
if (findIndex(args, arg => typeof arg === 'undefined') !== -1) return;
log(`Subscribed to observable ${key}(${args && args.join(',')})`);
const sub = multicall.watch(key, ...args).subscribe({
next: val => {
log('Got value from observable ' + key + ':', val);
setValue(val);
},
error: val => {
log('Got error from observable ' + key + ':', val);
setValue(null);
}
});
// ...
Some notes:
- The multicall library is developed by makerdao for smart contract dApp frontends.
- Multicall is in a sense a query library.
- A query is identified by a key, which has a unique schema/model for its value. For example,
userVaultsList
is a query key.
Digging further, I couldn't find any further mention of where watch
was set, and hence, userVaultsList
. So I decided to do my favourite thing and search the makerdao Github organisation using Sourcegraph instead. Voilà! It was defined in a package called dai-plugin-mcd. Here it is:
export const userVaultsList = {
generate: address => ({
dependencies: ({ get }) => {
const cdpManagerAddress = get('smartContract').getContractAddress(
'CDP_MANAGER'
);
return [
[USER_VAULT_IDS, cdpManagerAddress, [PROXY_ADDRESS, address]],
[USER_VAULT_ADDRESSES, cdpManagerAddress, [PROXY_ADDRESS, address]],
[USER_VAULT_TYPES, cdpManagerAddress, [PROXY_ADDRESS, address]]
];
},
computed: (ids, addresses, types) =>
ids.reduce(
(acc, id, idx) => [
...acc,
{
vaultId: id,
vaultAddress: addresses[idx],
vaultType: types[idx]
}
],
[]
)
}),
validate: {
args: validateAddress`Invalid address for userVaultsList: ${'address'}`
}
};
It looks like this query manager is a bit more sophisticated than I thought. Presumed operation:
generate
takes the arguments we've given earlier in watch.userVaultsList(), so, the user's address.- it returns an object with a list of dependencies, themselves being queries that should be executed before the current query can resolve. These are
USER_VAULT_IDS
,USER_VAULT_ADDRESSES
andUSER_VAULT_TYPES
. - Once these are fetched,
computed
will be called with the returning query values, and be used to compute the query data. Dependent queries are presumably cached by their key, and recomputed when dependencies change.
Patterns
- Unique data queries that are cached by key (
USER_VAULT_IDS
,USER_VAULT_ADDRESSES
,viewedAddressVaults
). - Queries are bundled into the
useObservable
hook, which stores state using theuseState
hook and runs the query inside auseEffect
block. - These queries are further abstracted away into a provider -
VaultProvider
. It seems a lot of the state management, including the Ethereum transaction flow, are also abstracted this way (TransactionManagerProvider
). - Queries initially return
undefined
, which maps to a loading view in the respective page like<Overview/>
.