Last active
December 14, 2023 08:19
-
-
Save a-laughlin/b7d0b05a8c0393d26d524658dbd2ed24 to your computer and use it in GitHub Desktop.
Leaf Query Factories, Examples, and Usage
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
node_modules | |
package-lock.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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']) | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
},{}) | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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