Skip to content

Instantly share code, notes, and snippets.

@a-laughlin
Last active December 14, 2023 08:19
Show Gist options
  • Save a-laughlin/b7d0b05a8c0393d26d524658dbd2ed24 to your computer and use it in GitHub Desktop.
Save a-laughlin/b7d0b05a8c0393d26d524658dbd2ed24 to your computer and use it in GitHub Desktop.
Leaf Query Factories, Examples, and Usage
node_modules
package-lock.json
import {makeLeafQuery, makeListLeafQuery, makeListSelectorPairs} from './leaf-query-factories';
import {gql} from "@apollo/client";
export const [useUserName, setUserName] = makeLeafQuery<string,{user:{name:string}}>({
query: gql`query User{user{name}}`,
selector: (data)=>data.user.name,
setter: (data, name)=>({...data, user:{...data.user, name}})
});
export const {
useListKeys:useTodoKeys,
name:[useTodoName, setTodoName],
status:[useTodoStatus, setTodoStatus]
} = makeListLeafQuery<'todos',{id:string,name:string,status:string}>({
query: gql`query Todos{todos{id,name,status}}`,
selectIds:(data)=>data.todos.map(t=>t.id),
hookKeys:makeListSelectorPairs('todos',['name','status'])
});
import { type DocumentNode } from "@apollo/client";
import { useState, useEffect} from "react";
import {ApolloClient,InMemoryCache} from "@apollo/client";
const client = new ApolloClient({
uri: '...example',
cache:new InMemoryCache()
});
/**
* @name makeLeafQuery
* @description Example leaf query factory for primitive values
*/
export const makeLeafQuery = <
const Value extends string = string,
const InData extends Record<string,any> = Record<string,any>,
const OutData extends InData = InData
>({
query,
selector,
setter
}:{
query:DocumentNode,
selector:(data:InData)=>Value,
setter: (data:InData,value:Value)=> OutData,
})=>{
// Use Apollo client's query change listener, instead of its hooks, to prevent rerendering on unrelated query property changes.
const observable = client.watchQuery<InData>({query});
// useHook: Re-renders only when the selected value, not some other branch of the query, changes.
// The selector returns a primitive, and setState(value) only rerenders when
// the value is changed, so memoization occurs with no special code.
const useHook = ()=>{
const [value, setValue] = useState(selector(observable.getCurrentResult().data));
useEffect(()=>{
const subscription = observable.subscribe((result)=>setValue(selector(result.data)));
return ()=>subscription.unsubscribe();
}, []);
return value;
}
// setHook: writes the leaf change back to the cache
// client.mutate() could be leveraged instead of writeQuery for remote state
const setHook = (value)=>{
client.writeQuery({
query,
data:setter(observable.getCurrentResult().data, value)
});
}
return [useHook, setHook] as [
()=>ReturnType<typeof selector>,
(value:Value)=>void
];
}
/**
* @name makeListLeafQuery
* @description Example leaf query factory for list values. Note that there are more performant ways to implement this with Apollo. See https://www.apollographql.com/docs/react/caching/cache-interaction).
*/
export const makeListLeafQuery = <
const DataKey extends string,
const Item extends Record<string, any>,
Data extends Record<DataKey, ReadonlyArray<Item>> = Record<DataKey, ReadonlyArray<Item>>,
ItemKeys extends keyof Item = keyof Item,
IdKey extends ItemKeys = ItemKeys,
ID extends Item[IdKey] = Item[IdKey],
SelectIds extends (data:Data)=>ID[] = (data:Data)=>ID[],
HooksByKey extends {
[k in ItemKeys]?:{
selector:(data:Data,id:ID) => Item[k],
setter:(data:Data,id:ID, value:Item[k])=>void
}
} = {
[k in ItemKeys]?:{
selector:(data:Data,id:ID) => Item[k],
setter:(data:Data,id:ID, value:Item[k])=>void
}
},
HookKeys extends keyof HooksByKey = keyof HooksByKey
>({
query,
selectIds,
hookKeys,
}:{query:DocumentNode, selectIds:SelectIds,hookKeys:HooksByKey}) =>{
// Use Apollo client's query change listener, instead of its hooks, to prevent rerendering on unrelated query property changes.
const observable = client.watchQuery({query});
// we need to pass down a key for list item leaf queries to know which value to get.
// useListKeysHook gets a list of all the ids, both for react to avoid rerendering unchanged list items
// and for leaf query hooks in the list item components to get their data.
const useListKeysHook:()=>ReturnType<SelectIds> = ()=>{
const [ids, setValue] = useState(selectIds(observable.getCurrentResult().data));
useEffect(()=>{
const subscription = observable.subscribe((result)=>setValue(selectIds(result.data)));
return ()=>subscription.unsubscribe();
}, []);
return ids;
}
// create hooks for each key in hookKeys
// @ts-expect-error typing selector and setter internally don't affect the result so aren't worth the effort
const result = Object.entries(hookKeys).reduce((hooksByKey,[key,{selector,setter}])=>{
// useHook: Re-renders only when the selected value, not some other branch of the query, changes.
// The selector returns a primitive, and setState(value) only rerenders when
// the value is changed, so memoization occurs with no special code.
const useHook:(id:ID)=>ReturnType<typeof selector> = (id)=>{
const [value, setValue] = useState(selector(observable.getCurrentResult().data, id));
useEffect(()=>{
const subscription = observable.subscribe((data)=>setValue(selector(data, id)));
return ()=>subscription.unsubscribe();
}, []);
return value;
}
// setHook: writes the leaf change back to the cache
// client.mutate() could be leveraged instead of writeQuery for remote state
const setHook:(id:ID, value:Parameters<typeof setter>[2])=>void = (id, value)=>{
client.writeQuery({
query,
data:setter(observable.getCurrentResult().data, id, value)
});
}
hooksByKey[key]=[useHook,setHook];
return hooksByKey;
},{}) as {useListKeys:()=>ReturnType<SelectIds>} & Required<{[k in HookKeys]: [
(id:ID) => ReturnType<NonNullable<HooksByKey[k]>['selector']>,
(id:ID, value:Parameters<NonNullable<HooksByKey[k]>['setter']>[2]) => void
]
}>;
result.useListKeys = useListKeysHook;
return result;
}
export const makeListSelectorPairs = (dataKey:string,itemProps:string[],idKey='id')=>{
return itemProps.reduce((acc,prop)=>{
acc[prop]=({
selector:(data,id)=>data[dataKey].find(item=>item[idKey] === id)[prop],
setter:(data,id,value)=>data[dataKey].map(item=>item[idKey] === id ? {...item,[prop]:value} : item)
});
return acc;
},{})
}
import React from "react";
import { setUserName, useTodoKeys, useTodoName, useTodoStatus, useUserName } from './leaf-query-examples';
export const App = ()=>
<>
<UserName/>
<Todos/>
</>;
const UserName = ()=> <input value={useUserName()} onChange={e=>setUserName(e.target.value)} />;
const Todos = ()=>
<ul>
{useTodoKeys().map(key=><Todo key={key} />)}
</ul>;
const Todo = ({key})=>
<li key={key}>
<span>{useTodoName(key)}</span>
<span>{useTodoStatus(key)}</span>
</li>;
{
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"@apollo/client": "3.8.8"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment