Hono Composables
Introduction
I'm a huge fan of Vue's composables for creating reusable bites of code for use in your application.
I wanted to employ this same approach when working with Hono and it's pretty much the same as working with Vue composables. This follows pretty much the same approach as creating Vue composables but I've tried to provide a general rule for creating them.
Let's get started.
Baseline
Composables in this context are just functions that provide child functions and or state that will be available for the lifecycle of a request.
In a basic sense, this ends up looking like this:
export const useMyComposable = () => {
const sayHello = (s: string) => {
return `Hello ${s}`;
}
return {
sayHello,
}
}
You'll notice that we're following the same naming convention as in VueJS and the same return convention. This is purely a preference on my part but I feel like it keeps things consistent, particularly when you're working across both a Hono backend and VueJS frontent.
Making it more Hono-esque
Obviously since these are going to be used in a Hono app, the first thing we'll want to do is pass in the context.
import type { Context } from 'hono';
export const useMyComposable = (c: Context) => {
const sayHello = (s: string) => {
return `Hello ${s}`;
}
return {
sayHello,
}
}
We can use this to access environment variables, session information or anything else that's stored in the request context.
A more practical example
Let's say we have a data model of projects and tickets. Projects contain many tickets, a simple one-to-many relationship. Tickets are a complete sub-entity in this data model. (I know this probably wouldn't the case in an actual system but it will serve well for this example.)
Now, let's say these are accessed from another API. This API has the following endpoints:
|- GET /projects (Get a list of projects)
|- GET /projects/{id} (Get project by ID)
|- POST /projects (Create a project)
|- PATCH /projects/{id} (Update a project)
|- DELETE /projects/{id} (Delete a project)
|- GET /projects/{id}/tickets (Get a list of tickets for a project)
|- GET /projects/{id}/tickets/{id} (Get a specific ticket within a project by ID)
|- POST /projects/{id}/tickets (Create a new ticket for a project)
|- PATCH /projects/{id}/tickets/{id} (Update a ticket wihtin a project)
|- DELETE /projects/{id}/tickets/{id} (Delete a ticket within a project)
We'll create two services to support our needs and utilise the useFetch composable I created in a previous post, with a few updates.
// useFetch.ts
import defu from 'defu';
import type { Context } from 'hono';
import { ofetch, type FetchOptions } from 'ofetch';
export const useFetch = <TC extends Context>(
c: TC,
baseURL: string,
options?: FetchOptions
): UseFetchComposable => {
if (!baseURL) {
log(
'`baseURL` is undefined. You may experience unexpected behaviour when making external requests.',
reqID
);
}
const defaults: FetchOptions = {
baseURL,
};
const opts = defu(options, defaults);
const $fetch = ofetch.create(opts);
return {
$fetch,
};
};
In this updated composable, I'm passing in the context as the first argument, you would use this to access things like an access token stored in the session or access a request ID for logging.
For now, we'll use it as we expand our services.
useProjectService
Let's create our project service. We'll need methods to perform CRUD operations, I won't fill in the methods here, just provide the titles to show you what I'm going for.
// project.service.ts
import type { Context } from 'hono';
import { env } from 'hono/adapter';
export const useProjectService = (c: Context) => {
const { $fetch } = useFetch(c, `${env(c).EXTERNAL_SERVICE_BASE}/projects`);
const getProjectList = () => {
// Some function body using $fetch
};
const getProject = (id: number) => {
// Some function body using $fetch
}
const createProject = (data: Project) => { // The Project type here is just for illustrative purposes
// Some function body
}
const updateProject = (id: number, data: Partial<Project>) => { // Again, Project is just for illustrative purposes
// Some function body
}
const deleteProject = (id: number) => {
// Some function body
}
return {
getProjectList,
getProject,
createProject,
updateProject,
deleteProject,
}
};
You can see already how we're taking advantage of the composable nature with our useFetch composable. We're utilising it here but by passing in the base URL we're making it specific to the context in which we need it.
useTicketService
Now let's look at our ticket service. This differs slightly in how we set it up. Remember the ticket endpoints we're accessing from eariler?
|- GET /projects/{id}/tickets (Get a list of tickets for a project)
|- GET /projects/{id}/tickets/{id} (Get a specific ticket within a project by ID)
|- POST /projects/{id}/tickets (Create a new ticket for a project)
|- PATCH /projects/{id}/tickets/{id} (Update a ticket wihtin a project)
|- DELETE /projects/{id}/tickets/{id} (Delete a ticket within a project)
Each of these exist under a project. From a developer experience perspective, we want to match this to abstract away our need to provide this for each function within it.
// ticket.service.ts
import type { Context } from 'hono';
import { env } from 'hono/adapter';
export const useTicketService = (c: Context, projectId: number) => {
const { $fetch } = useFetch(c, `${env(c).EXTERNAL_SERVICE_BASE}/projects/${projectId}/tickets`);
const getTicketList = () => {
// Some function body using $fetch
};
const getTicket = (id: number) => {
// Some function body using $fetch
}
const createTicket = (data: Ticket) => { // The Ticket type here is just for illustrative purposes
// Some function body
}
const updateTicket = (id: number, data: Partial<Ticket>) => { // Again, Ticket is just for illustrative purposes
// Some function body
}
const deleteTicket = (id: number) => {
// Some function body
}
return {
getTicketList,
getTicket,
createTicket,
updateTicket,
deleteTicket,
}
};
With this project ID needed to instantiate our composable, it leaves the internal API as simple as the project service. We once again make use of the baseURL to make accessing endpoints incredibly simple.
Composables all the way down
The best thing about composables is using them within each other to make more complex actions easy.
Let's say, we wanted to be able to create a project and tickets in one API request. So our createProject method would also need us to be able to create tickets. But, the exteral API we're hitting only allows us to create projects without tickets and then add tickets afterwards. Rather than making separate method calls within our endpoint definition, we can do this within our service using our composables:
// project.service.ts
import type { Context } from 'hono';
import { env } from 'hono/adapter';
export const useProjectService = (c: Context) => {
// ...
const createProject = async (data: Project) => { // The Project type here is just for illustrative purposes
const { tickets, ...body } = data; // Separate the tickets out of our incoming data.
// Create the project
const project = await $fetch('', {
method: 'POST',
body: body,
});
// If we have some tickets to create, create them all at once.
if (tickets && tickets.length) {
const { createTicket } = useTicketService(c, projectID);
const createdTickets = await Promise.all(data.map((t) => createTicket(t)));
project.tickets = createdTickets;
}
return project;
}
// ...
return {
getProjectList,
getProject,
createProject,
updateProject,
deleteProject,
}
};
With this, we can easily create both our project and it's tickets in one go.
Wrapping Up
In summary, we've created clearly defined services that match the structure of the API we're trying to access and also provide a clean and easy to use developer experience.
When it comes to writing you own composables, I would follow these simple steps:
- Establish a clear naming convention (I like the useX approach as it makes it clear in your code that you're using these function)
- Provide the hono context only when necessary (That way you can create composables that aren't limited to the request lifecycle, they could be used in middleware or anywhere else)
- Annotate your composables with a return type (Create an interface with your functions as properties, you can then add doc strings that are readable when using the child functions)
Happy coding!