Type-Safe Eleventy Data Cascade Access with TSX and Preact Hooks
Kenneth G. Franqueiro
Posted on May 19, 2024
This post continues where Making Eleventy Data Traceable with TSX and Zod leaves off; it will be easier to follow after completing the steps in that post first.
My previous post culminated in having fully-typed (and validated) data available to top-level templates in TSX; from there, it is possible to build an entire component hierarchy, passing both Eleventy-supplied and custom data throughout.
This can turn out to be more cumbersome the more you componentize, as you may end up needing to pass properties down repeatedly, even in the case of Eleventy-supplied data.
What if we could access and validate data needed directly within each component rather than all-at-once at the top level? This would be more in line with how Eleventy templates are typically written, but with the same typing and validation assurances of the previous approach.
Since we've already started down the path of TSX, this sounds like something that context could address...
Replacing jsx-async-runtime with Preact
jsx-async-runtime
doesn't know anything about the concept of context, as that is a feature specific to React and Preact. We'll use the latter, since it's far more lightweight. Bear in mind that we're only using this within the Eleventy build process itself; it will not add any client-side JavaScript.
First, let's update dependencies:
npm rm jsx-async-runtime
npm i preact preact-render-to-string
# (or yarn add/remove, etc. as desired in place of npm)
Update tsconfig.json
to reference the correct JSX runtime:
"jsxImportSource": "preact",
Then update the tsx
transform in eleventy.config.ts
:
import type { VNode } from "preact";
import { renderToString } from "preact-render-to-string";
// ...
eleventyConfig.addTransform("tsx", (content: VNode) =>
renderToString(content)
);
The content
property we defined in EleventyProps
in the previous post also needs updating:
import type { ComponentChildren } from "preact";
// ...
export interface EleventyProps {
content: ComponentChildren;
eleventy: EleventyMeta;
page: EleventyPage;
}
Handling TSX vs. non-TSX Content
In the previous post, I mentioned that content
from both TSX and non-TSX templates would Just Work with jsx-async-runtime
because it doesn't escape HTML in JavaScript by default.
Preact does escape by default, which means that while TSX template content (received as JSX children) can be handled as before...
<body>{content}</body>
...non-TSX template content (received as strings) needs to remain unescaped, since it is expected to contain markup:
<body dangerouslySetInnerHTML={{ __html: content }} />
One layout can support both TSX and non-TSX templates by checking whether content
is a string:
typeof content === "string" ? (
<body dangerouslySetInnerHTML={{ __html: content }} />
) : (
<body>{content}</body>
)
If you find you need to support both formats across many layouts, you can create a helper function like this:
import { type ComponentChildren } from "preact";
export const resolveContent = (content: ComponentChildren) =>
typeof content === "string"
? { dangerouslySetInnerHTML: { __html: content } }
: { children: content };
// Example usage:
// <body {...resolveContent(content)} />
If you're also planning to use both TSX and non-TSX layouts, you'll find that the tsx
transform needs to be made more robust than it was in the original JetBrains series, to avoid running renderToString
on non-TSX layouts (because, like above, content
will already be a string):
eleventyConfig.addTransform("tsx", (content: string | VNode) =>
typeof content === "string" ? content : renderToString(content)
);
Creating the Context Object
Creating a context requires setting up a default value for it. Rather than write a bunch of lines for a value that should never be used, we're going to take an intentional shortcut here:
const DataContext =
createContext(null as unknown as EleventyProps);
This cast ensures that the type returned when consuming the context is always EleventyProps
; meanwhile, the default null
value will cause immediate and loud failures in the build process if we ever forget to hook up the provider.
Provider Setup
We can save ourselves some work - and make it as easy as possible to remember to hook up the provider - by creating a helper for TSX templates' render
functions:
import { type ComponentType } from "preact";
// ...
export const createRender =
<T extends EleventyProps>(Component: ComponentType) =>
(props: T) =>
(
<DataContext.Provider value={props}>
<Component />
</DataContext.Provider>
);
The helper function takes care of passing data from Eleventy to the provider; all we need to do is call it with our layout component:
const Layout = () => ...;
export const render = createRender(Layout);
I intentionally skipped over the layout component here; we'll get to that next.
Consuming the Context
Let's set up a hook to consume the data cascade from the context, and validate any custom data we're expecting. (This takes the place of createEleventyComponent
in the previous post.)
import { useContext } from "preact/hooks";
import type { output } from "zod";
export const useData = <T extends ZodTypeAny>(schema: T) => {
const data = useContext(DataContext);
const parsed = schema.parse(data) as output<T>;
return {
...data,
...parsed,
};
};
For convenience, we can also expose a simpler hook for cases where only Eleventy's own data (which we previously defined in EleventyProps
) is needed:
export const useEleventyData = () => useContext(DataContext);
With the provider hooked up and the hooks in place, we can now consume Eleventy data without needing to think about props:
const Layout = () => {
const { content, title } = useData(
z.object({
title: z.string().min(1),
})
);
return (
<html>
<head>
<title>{title}</title>
</head>
<body {...resolveContent(content)} />
</html>
);
};
export const render = createRender(Layout);
As with the previous approach, we only need to validate custom data, while Eleventy-supplied data is assumed from our typings.
Further Demonstration
We have effectively moved our schema usage to within individual components, instead of wrapped around the entire layout's invocation. The value of this approach is that each component can validate the specific data it needs in exactly the same way as seen above, with no need to pass props around.
To better illustrate this, let's reorganize the layout into smaller components with more focused responsibilities.
Meta
This component will handle adding metadata to the document head, using title
and optionally description
from custom data.
const Meta = () => {
const { description, title } = useData(
z.object({
description: z.string().optional(),
title: z.string().min(1),
})
);
return (
<>
<title>{title}</title>
<meta name="og:title" content={title} />
{!!description && (
<>
<meta name="description" content={description} />
<meta name="og:description" content={description} />
</>
)}
</>
);
};
Header
This component renders a (oversimplified) site header with navigation links. It relies on page.url
supplied by Eleventy to determine whether to set aria-current
on each link. This can use the simpler useEleventyData
hook, since it doesn't need custom data and therefore has nothing to validate.
const Header = () => {
const { page } = useEleventyData();
return (
<header>
<nav aria-label="Site">
<ul>
<li>
<a href="/" aria-current={page.url === "/" ? "page" : undefined}>Home</a>
</li>
<li>
<a href="/about/" aria-current={page.url === "/about/" ? "page" : undefined}>About</a>
</li>
</ul>
</nav>
</header>
);
};
Layout
Finally, this is what the Layout
component looks like, using the two other components defined above:
const Layout = () => {
const { content } = useEleventyData();
return (
<html>
<head>
<Meta />
</head>
<Header />
<body {...resolveContent(content)} />
</html>
);
};
Similar to the Header
component, the layout only needs access to Eleventy-supplied data.
Meta
is the only component out of the three that references custom data, so it's the only one that calls useData
and specifies a schema. In more realistic scenarios, it's quite possible that multiple components will depend on the same properties in custom data, in which case it may make sense to centralize schemas for reusable subsets, and/or centralize a superset and make use of pick
or omit
.
Conclusion
This definitely started out as an experiment, and I imagine it will seem like overkill for some projects. I think it's a nice option to have in contrast to needing to pass down props all the time. Either way, I'm glad to have options for making Eleventy projects more maintainable going forward.
Posted on May 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.