unctx gives plain JavaScript the Composition API pattern
๐ฆ Composables in vanilla JS
At a glance
- What is it?
- unctx is a small TypeScript library that lets a library expose a composable, a function that reads an ambient context, without depending on Vue or React. The interesting part is what happens across await boundaries, where the library offers three answers: a manual cache, Node's AsyncLocalStorage, and a build time transform that restores context after every await.
- Who is it for?
- Adopt unctx if you are writing a framework agnostic library that wants to expose composables the way Vue exposes them, and you are willing to reason about context lifetime across async boundaries. Do not adopt it if your library only needs a plain module level singleton, since the whole point is scoped context rather than globals.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The Vue pattern, without Vue
unctx is described as composables in vanilla JavaScript, with the tagline Composition-API in Vanilla js. The origin story is in the README: Vue.js introduced a pattern called the Composition API that allows organizing complex logic by splitting it into reusable functions and grouping in logical order, and unctx allows implementing that pattern in your JavaScript libraries without hassle.
That is the whole value. A library that wants to expose a composable, a function such as useTheme that reads a theme, a request context or a logger, normally has to either take the value as an argument, which pushes the wiring to every call site, or put it in a module level variable, which breaks as soon as two instances exist. unctx sits between those: the library creates a context, hands out an access function, and the consumer wraps the call site so the value is in scope while the composable runs.
The audience is library authors, not application developers. If you are building an application, you probably want whatever context mechanism your framework already has. If you are building a library that has to work in any framework, unctx gives you a way to offer the ergonomic API without taking a dependency on a framework.
The primary language is TypeScript, the licence is MIT, the default branch is main, the last push was on 2026-10-19, and the release history shows v3.0.0-beta.2 in July 2026, v3.0.0 on 2026-07-16 and v3.0.1 on 2026-08-20.
A library defines the context, the consumer calls it
The usage pattern has two halves, and the library side is the part you write.
In your library you create a context and export its accessor:
import { createContext } from "unctx";
const ctx = createContext();
export const useAwesome = ctx.use;
// ...
ctx.call({ test: 1 }, () => {
// This is similar to the vue setup function
// Any function called here can use `useAwesome` to get { test: 1 }
});The user code is then just this:
import { useAwesome } from "awesome-lib";
// ...
function setup() {
const ctx = useAwesome();
}The comments in the README make the intent explicit: ctx.call is similar to the Vue setup function, and any function called inside the callback can use useAwesome to get the value. The consumer never imports the context object, only the accessor function, which is what keeps the dependency one-directional.
One behaviour is worth internalising before you ship it. The README notes that when no context is presented, ctx.use will throw an error, and that ctx.tryUse is there for tolerant usages that return a nullable context. So there are two accessors with deliberately different failure modes, and choosing between them at the call site is a decision about whether a missing context is a programming error or a legitimate state.
The value passed to ctx.call in the example is the context value itself, an arbitrary object. Nothing about it is special to the library.
The namespace trick for when two copies get loaded
There is a failure mode that is specific to libraries and that unctx addresses head on: two copies of the same library in one bundle.
If your package is consumed by two different dependency trees, each tree may install its own copy, and each copy would have its own context object. A composable from copy A would then be unable to see a context created by copy B, and the symptom would be a confusing null or a thrown error in an application that looks correctly wired.
The fix is a shared global namespace keyed by a string, kept in globalThis:
import { useContext, getContext } from "unctx";
const useAwesome = useContext("awesome-lib");
// or
// const awesomeContext = getContext('awesome-lib')useContext and getContext look up the same context by key, so both copies of the library find the same object. There is also a createNamespace utility for more advanced internal use cases.
The README warns about two things here, and both are easy to get wrong. Use a verbose name for the key to avoid conflicts with other JavaScript libraries, and using the npm package name is recommended. And using symbols has no effect, since it still causes the multiple context issues, because two copies of the library would each hold a different symbol.
That last point is a nice illustration of the trade: the global registry is a pragmatic fix for a packaging problem, and it trades a private namespace for a public, string-keyed one.
The await boundary is where context disappears
The limitation that shapes everything else in unctx is stated in the README without hedging. Using context is only possible in non-async usages and only before the first await statement, and the reason given is to make sure context is not shared between concurrent calls.
The example makes the behaviour concrete:
async function setup() {
console.log(useAwesome()); // Returns context
setTimeout(() => {
console.log(useAwesome());
}, 1); // Returns null
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(useAwesome()); // Returns null
}The first call returns the context, the call inside setTimeout returns null, and the call after the await returns null. The reason is the same reason the limitation exists: a module level variable cannot tell one concurrent call from another, so unctx refuses to hand out a value once the synchronous section of the call has ended.
The first workaround is the obvious one, caching the context in a local variable:
async function setup() {
const ctx = useAwesome(); // We can directly access cached version of ctx
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(ctx);
}The README is candid that this is not always an elegant and easy way, because it means making a variable and passing it around, and it points out the obvious tension: this is the purpose of unctx to make sure context is magically available everywhere in composables. So the local variable is a fallback, not the design.
The real design answer is that a context is scoped to a synchronous call, and the two mechanisms below exist to extend that scope deliberately.
AsyncLocalStorage on Node, and a transform everywhere else
Unctx supports Node.js AsyncLocalStorage as a native way to preserve and track async contexts, and it is enabled with one option:
import { createContext } from "unctx";
const ctx = createContext({ asyncContext: true });
ctx.call("123", () => {
setTimeout(() => {
// Prints 123
console.log(ctx.use());
}, 100);
});The implementation is auto-detected from globalThis.AsyncLocalStorage or the node:async_hooks built-in module, and if neither is available, or you want a custom implementation, you can pass the AsyncLocalStorage option explicitly.
That is the clean answer on Node, and it comes with the platform caveat: native async context is not supported in all platforms yet.
For those platforms, unctx provides a build time solution that transforms async syntax to automatically restore context after each async/await statement, which requires a bundler such as Rollup, Vite or Webpack. The peer dependencies are optional and only installed if you use the transform:
npx nypm i -D unplugin magic-string oxc-parser
# or, if you already use rolldown:
npx nypm i -D unplugin magic-string rolldownYou then register the plugin, and use ctx.callAsync instead of ctx.call:
await ctx.callAsync("test", setup);Two details are easy to miss. callAsync is not transformed by default, so it has to be added to the plugin's asyncFunctions option. And any async function that requires context should be wrapped with withAsyncContext:
import { withAsyncContext } from "unctx";
const setup = withAsyncContext(async () => {
console.log(useAwesome()); // Returns context
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(useAwesome()); // Still returns context with dark magic!
});The transform is also usable without a bundler
If you are not using a bundler, or you are building your own integration, the same transform is available standalone from unctx/transform, and it only needs magic-string and an oxc parser, with unplugin not required:
import { createTransformer } from "unctx/transform";
const transformer = await createTransformer({
// asyncFunctions: ["withAsyncContext"],
// helperModule: "unctx",
// helperName: "executeAsync",
// objectDefinitions: {},
});
const result = transformer.transform(code);
if (result) {
console.log(result.code); // Transformed code
console.log(result.magicString.generateMap()); // Source map
}createTransformer is async because the parser is imported lazily, and it resolves to an object with a small, well described API. transform(code, { force? }) returns an object with code and magicString, or undefined when nothing was transformed, and passing force true skips the shouldTransform pre-check and always parses. shouldTransform(code) is a cheap check for whether the code contains a transformable call and an await. And filter is a { code: RegExp } used to skip files that cannot contain a transformable call.
The same regexp can be computed without loading the parser, via getTransformFilter(options), which is useful for pre-filtering files:
import { getTransformFilter } from "unctx/transform";
const { code: codeFilter } = getTransformFilter();The design here is sensible. A transform that has to parse every file in your bundle is slow, so there is a cheap pre-check, and a filter for files that cannot possibly need it, and the standalone entry point exists so the transform can be embedded in tools that have their own bundler integration.
Package shape, versions and the surface you inherit
The published package has three entry points, which maps onto the three ways the library is used: the root export for the runtime API, ./transform for the standalone transformer, and ./plugin for the bundler integration. The types are published as .d.mts, and the package is marked with sideEffects false, so a bundler can drop unused exports. The files field contains only dist, so the published tarball is the build output and nothing else.
The build uses obuild, the tests run under vitest with coverage, the type check is a separate tsc --noEmit pass, and the release script runs the tests, the build, changelogen for the changelog and then publishes. The package manager is pinned to pnpm, and there is a renovate.json for dependency updates.
The version history deserves a note. v3.0.0 landed on 2026-07-16, after a v3.0.0-beta.2 on 2026-07-12, and v3.0.1 followed on 2026-08-20. A major version bump for a library this small suggests a breaking change, plausibly in the transform or the async context behaviour, so an upgrade from v2 is worth reading in the CHANGELOG.md rather than doing blind.
One thing to plan for is that the transform approach puts a build step between your source and your users' bundles. That is a real cost for a library: your consumers now need a bundler that has the plugin configured, or they get the untransformed behaviour where context silently disappears after an await. The AsyncLocalStorage route avoids that on Node, and the README's framing of the transform as a fallback for platforms without native async context is the honest way to think about it.
Editorial conclusion
Adopt unctx if you are writing a framework agnostic library that wants to expose composables the way Vue exposes them, and you are willing to reason about context lifetime across async boundaries. Do not adopt it if your library only needs a plain module level singleton, since the whole point is scoped context rather than globals. Before you rely on it, check the semantics of your platform, because the README states that context is only available before the first await statement unless you opt into AsyncLocalStorage or the transform, and decide which of the three approaches you are willing to ship to your users.
Frequently asked questions
What is unctx and what problem does it solve?
unctx is a library for composables in vanilla JavaScript. It lets a library expose a function that reads an ambient context the way Vue's Composition API does, without depending on Vue or any other framework, by using a shared global namespace keyed by a string so multiple copies of a package still find the same context.
Why does unctx lose context after an await?
The README states that using context is only possible in non-async usages and before the first await statement, so that context is not shared between concurrent calls. The documented workarounds are caching the value in a local variable, enabling AsyncLocalStorage on Node with the asyncContext option, or using the build time transform that restores context after each await.
How do I use unctx in a library and in user code?
In the library you call createContext, export ctx.use as your composable, and wrap the setup code in ctx.call. The consumer then imports only the exported accessor and calls it inside their own setup function, never importing the context object itself.
What does the unctx transform plugin need?
A bundler such as Rollup, Vite or Webpack, plus the optional peer dependencies unplugin and magic-string and an oxc parser, either oxc-parser directly or rolldown. The standalone transformer from unctx/transform needs only magic-string and an oxc parser, and unplugin is not required.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/unjs-unctx)Was this useful?