Index āŗ 11. Components and fragments
Learn how to break up your pages and apps into smaller, well-encapsulated, and easily maintainable components and fragments.
Topics covered
- Refactoring pages to use components and fragments.
- Components (.component.js files).
- Fragments (.fragment.js files).
- Importing components and fragments.
- How to scope styles.
- Component and fragment properties (props).
- Passing HTML attributes to components and fragments.
Better coding through components and fragments
The Fetchiverse example in the last tutorial is only about 50 lines of code in a single file. While thatās fine for something so simple, in larger projects, it would help us to maintain our code if we break it up into smaller components and fragments.
Letās start by examining the layout of our list.
We have two major elements in each list item: the authorās avatar and the post content itself. These would be prime candidates to make into separate fragments or components.
So letās do that:
export default async function route () {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postsResponse.json()
return kitten.html`
<h1>Aralās Public Fediverse Timeline</h1>
<ul>
${posts.map(post => kitten.html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`)}
</ul>
<style>
body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
h1 { font-size: 2.5em; text-align: center; }
ul { padding: 0; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
`
}
const Avatar = ({ post }) => kitten.html`
<a class='Avatar' href='${post.account.url}'>
<img src='${post.account.avatar}' alt='${post.account.username}ās avatar' />
</a>
<style>
.Avatar img {
width: 8em;
border-radius: 1em;
}
</style>
`
const Content = ({ post }) => kitten.html`
<div class='Content'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
))}
</div>
<style>
.Content { flex: 1; }
.Content p:first-of-type { margin-top: 0; }
.Content p { line-height: 1.5; }
.Content a:not(.Avatar) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
.Content img { max-width: 100%; }
/* Make sure posts donāt overflow their containers. */
.Content a {
word-break: break-all;
}
</style>
`
Notice how we split up the styles also and encapsulated them in the components that they pertain to, scoping them to the component itself using class prefixes.
You might be wondering what happens when more than one copy of a component is included on the page. Do the style tags get replicated? The short answer is no. Kitten is smart enough to deduplicate the style tags in your components before rendering the page. In fact, it gathers all the styles on your page into a single, neat <style>
tag in the <head>
of your page.
Once youāve separated your page into components and fragments, thereās no rule that says they must all be in the same file. Since they are just snippets of JavaScript, you can put each one in its own file and import them in.
š In Kitten, we call a custom HTML element that can be included as a custom tag a component and any other snippet of HTML, CSS, or JavaScript a fragment. They go in .component.js and .fragment.js files, respectively.
š” A good rule of thumb is that if an element will be reused in multiple places on a page and/or in multiple pages, itās a component. If it is a part of page that might be rendered separately from it (or makes sense to separate for organisational reasons to make maintenance easier), itās a fragment.
š” If youāre using a component for layout, you can also use a .layout.js extension. Like every other Kitten-specific extension, these are just JavaScript files but Kitten knows that these are server-side routes and should not be served as client-side JavaScript.
So hereās one way we could organise the code:
index.page.js
import Avatar from './Avatar.component.js'
import Content from './Content.component.js'
export default async function route () {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postsResponse.json()
return kitten.html`
<h1>Aralās Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
kitten.html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`
))}
</ul>
<style>
body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
h1 { font-size: 2.5em; text-align: center; }
ul { padding: 0; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
`
}
Avatar.component.js
export default ({ post }) => kitten.html`
<a class='Avatar' href='${post.account.url}'>
<img src='${post.account.avatar}' alt='${post.account.username}ās avatar' />
</a>
<style>
.Avatar img {
width: 8em;
border-radius: 1em;
}
</style>
`
Content.component.js
export default ({ post }) => kitten.html`
<div class='Content'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
))}
</div>
<style>
.Content { flex: 1; }
.Content p:first-of-type { margin-top: 0; }
.Content p { line-height: 1.5; }
.Content a:not(.Avatar) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
.Content img { max-width: 100%; }
/* Make sure posts donāt overflow their containers. */
.Content a {
word-break: break-all;
}
</style>
`
š” Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour.
Locality of behaviour is about keeping related functionality together so you can easily read through the code in a linear fashion.
In this example, I probably would have kept everything in a single file since itās so little code and since I donāt need to include the Avatar or Content components from any other routes. Donāt be afraid to experiment with how you organise your own projects. Soon, youāll develop a knack for knowing when youāve hit the sweet spot.
Component idiom
The component idiom in Kitten is for your components to have a single root element and for that element to have a class name thatās used to scope classes to it.
šŖ¤ You can have a component with multiple root elements but this will cause issues if you return just that component in an Ajax request to htmx. If you absolutely want to do this (not recommended), you must flatten the array returned by the component before sending it over the wire or else youāll get the following error:
š« Error ERR_INVALID_ARG_TYPE: The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received an instance of Array.
HTML attributes
In addition to the custom properties (or āpropsā) you can define and set on your components, you can also pass all other props (HTML attributes) to an element of your choosing within your component.
This is very useful as it means that you can use regular HTML attributes to listen for events and even use frameworks like htmx and Alpine.js with your components.
Take this simple example of a custom button. Notice how weāre passing the rest of the props ā obtained using the spread operator ā to the button
element. This allows us to listen for the onclick
event and display an alert dialogue in response to it.
MyButton.component.js
export default function ({label = 'Missing label prop (label="ā¦")', ...props}) {
return kitten.html`
<div class='MyButton'>
<button class='MyButton' ...${props}>${label}</button>
<style>
.MyButton button {
border: 2px solid darkviolet;
color: darkviolet;
border-radius: 0.25em;
padding: 0.25em 0.5em;
background: transparent;
font-size: 2em;
}
</style>
</div>
`
}
index.page.js
import MyButton from './MyButton.component.js'
export default () => kitten.html`
<h1>My button</h1>
<${MyButton} label='Press me!' onclick='alert("Thank you for pressing me!")' />
`
If, instead you wanted to use Alpine.js, this is how your index page would look:
import MyButton from './MyButton.component.js'
export default () => kitten.html`
<page alpinejs>
<h1>My button</h1>
<${MyButton}
label='Press me!'
x-data
@click='alert("Thank you for pressing me!")'
/>
`
š”For more advanced components, you can, of course, use htmx and Alpine.js inside your component and expose just your custom interface.
Now that you have a taste for what you can do with components and fragments, letās leave the Fetchiverse example aside for the time being and take a look at a powerful feature of components and fragments that you havenāt used yet: composition using slots.
Next tutorial: Slots