SVG Caching with <use>
I had an idea for caching SVG paths. Not the usual kind of async request caching of remote SVGs, but local re-use of DOM elements that have already rendered.
SVG's <use>
element allows re-use of an existing DOM element, without manually duplicating the node. It works like this:
<!-- Add an id to the element --><svg> <circle id="circle" cx="5" r="5" fill="black" /></svg><!-- Pass the id as href to <use> --><svg> <use href="#circle" /></svg><!-- The same SVG renders twice -->
Setup
When using an icon set like Feather in React, I prefer to use a higher-order component (HOC) and a generic Icon
component to render each icon with consistent properties. We'll use this HOC to demonstrate SVG caching:
import { memo } from 'react'const withIcon = (icon, opts) => { const Icon = props => { const { size = 24, color = 'currentColor' } = props return ( <svg viewBox="0 0 24 24" width={size} height={size} stroke="currentColor" style={{ color }} dangerouslySetInnerHTML={{ __html: icon }} /> ) } return memo(Icon)}export default withIcon
Each icon is simply the SVG contents wrapped with the HOC:
const ArrowLeft = withIcon('<path d="M21 12H3m0 0l6.146-6M3 12l6.146 6" />')
Caching
We'll use React context to add an icon cache. First, create a new context and the appropriate hook to access it:
export const IconCache = React.createContext(null)export const useIconCache = () => React.useContext(IconCache)
Setup the provider at the application root. The cache will be a plain, empty object where each key is the icon string and each value is the cached id.
const App = () => ( <IconCache.Provider value={{}}>{/* ... */}</IconCache.Provider>)
Inside of Icon
, read the cache from context and check if this icon has a cached id. If not, generate the new id and add it to the cache:
const cache = useIconCache()let cachedId = cache[icon]if (!cachedId) { cachedId = `icon-` + hash(icon).toString(16) cache[icon] = cachedId}
Generate a stable id by hashing the icon using the fnv1a 1 algorithm (commonly used in CSS-in-JS libraries) and then converting it to hexadecimal for a smaller string:
import hash from 'fnv1a'
If we have a cached id, we can render the <use>
tag instead of inserting the entire icon again. If this icon has not rendered before, wrap it in a group tag and attach the unique id.
return ( <svg viewBox="0 0 24 24" width={size} height={size} stroke="currentColor" style={{ color }} dangerouslySetInnerHTML={{ __html: cachedId ? `<use href="#${cachedId}" />` : `<g id="${id}">${icon}</g>` }} />)
Conclusion
Here's our new withIcon
HOC with caching:
import { memo } from 'react'import hash from 'fnv1a'export const IconCache = React.createContext({})export const useIconCache = () => React.useContext(IconCache)const withIcon = icon => { const Icon = props => { const { size = 24, color = 'currentColor' } = props const cache = useIconCache() const cachedId = cache[icon] let id if (!cachedId) { id = 'icon-' + hash(icon).toString(16) cache[icon] = id } return ( <svg viewBox="0 0 24 24" width={size} height={size} stroke="currentColor" style={{ color }} dangerouslySetInnerHTML={{ __html: cachedId ? `<use href="#${cachedId}" />` : `<g id="${id}">${icon}</g>` }} /> ) } return memo(Icon)}export default withIcon
Rendering the same icon multiple times will reuse existing DOM elements, decreasing the size of your HTML:
/* React */<IconCache.Provider value={{}}> <ArrowLeft /> <ArrowLeft /> <ArrowLeft /></IconCache.Provider>/* HTML Output: <svg> <g id="icon-dacb5a47"><path d="M21 12H3m0 0l6.146-6M3 12l6.146 6" /></g> </svg> <svg> <use href="#icon-dacb5a47" /> </svg> <svg> <use href="#icon-dacb5a47" /> </svg>*/
In this example, the cached version is about 40% fewer characters!
You can still customize each icon, because the props apply to the outer svg element and don't involve the inner elements at all:
<ArrowLeft /><ArrowLeft size={30} color="blue" /><ArrowLeft size={50} color="red" />
Here's a live demo and the demo source code.
- You don't have to use fnv1a, any stable id generation technique will work. Just make sure it's consistent between server and client to avoid hydration mismatch.