Skip to main content

typectx v0.4 and new @typectx/react adapter are out!

· 6 min read
@someone635

Version 0.4 of typectx has two potentially breaking changes: the reassemble() and $$($$supplier).assemble() APIs have been merged, removing the need for the reassemble() function altogether. Also, the @typectx/react adapter package have been introduced to address a critical performance issue that was overlooked in previous typectx version in React client components: referential integrity of dynamically generated components.

Let's dive into what changed and why it matters.

The Breaking Change: Goodbye reassemble(), Hello $$($$supplier).assemble()

In v0.3, when you wanted to rebuild an already-assembled product with new context, you'd call reassemble() directly on the product:

// v0.3 - The old way
const $$sendMoney = market.offer("send-money").asProduct({
suppliers: [$$addWalletEntry, $$session],
assemblers: [$$anyNewProductToAssembleSupplier]
factory: ($, $$) => {
return (toUserId: string, amount: number) => {
const addWalletEntry = $($$addWalletEntry).unpack()
addWalletEntry(-amount)

// Reassemble on the product itself
const $addWalletEntry = $($$addWalletEntry)
const addTargetWalletEntry = $addWalletEntry
.reassemble(index($$session.pack({ userId: toUserId })))
.unpack()

addTargetWalletEntry(amount)
}
}
})

The $$ function was only used to assemble NEW products from the assemblers list. $$ had no context of what supplies were already available, it was only used to properly swap the $$assembler with its corresponding mock if one was provided at the entry-point of the app.

But, turns out $$ can do much more.

In v0.4, reassemble() is gone, and $$ can now be called on assemblers AND on any already built supplier, optional or hired supplier. It also tries to preserve already-built supplies that don't need to update, just like reassemble() did, for optimal performance,

// v0.4 - The new way
const $$sendMoney = market.offer("send-money").asProduct({
suppliers: [$$addWalletEntry, $$session],
factory: ($, $$) => {
return (toUserId: string, amount: number) => {
const addWalletEntry = $($$addWalletEntry).unpack()
addWalletEntry(-amount)

// Access the supplier through $$ and call assemble()
const addTargetWalletEntry = $$($$addWalletEntry)
.assemble(index($$session.pack({ userId: toUserId })))
.unpack()

addTargetWalletEntry(amount)
}
}
})

This not only simplifies the API surface but also ensures consistent behavior for context propagation, mock handling, and type inference across your entire dependency tree.

Enter @typectx/react: Solving the Referential Integrity Problem

Consider this naive approach to integrating typectx with React:

// ❌ The problematic way
const $$Parent = market.offer("Parent").asProduct({
assemblers: [$$Child],
optionals: [$$theme],
factory: ($, $$) =>
function Parent() {
const [theme, setTheme] = useState<"light" | "dark">("light")

// Every render creates a NEW Child component
const Child = $$($$Child)
.assemble(index($$theme.pack(theme)))
.unpack()

return <Child />
}
})

This looks reasonable, but it has a devastating flaw: every time theme changes, a brand new Child component is created.

In React's world, component identity matters. When you render <Child /> and then render a different <Child /> (different by Object.is), React:

  • Unmounts the old component entirely
  • Mounts a new one from scratch
  • Destroys all state in the component tree below

Your users would see inputs clearing, scroll positions resetting, and animations restarting on every theme change. React's reconciliation optimizations? Gone. The React Compiler's work? Wasted.

How @typectx/react Fixes This

The @typectx/react package introduces two new hooks: useInit$ and useAssembleComponent.

// ✅ The correct way with @typectx/react
import { useInit$, useAssembleComponent } from "@typectx/react"

const $$Parent = market.offer("Parent").asProduct({
assemblers: [$$Child],
optionals: [$$theme],
factory: (init$, $$) =>
function Parent() {
const $ = useInit$(init$)
const [theme, setTheme] = useState<"light" | "dark">("light")

const $Child = useAssembleComponent(
$$($$Child),
index($$theme.pack(theme))
)

// useAssembleComponent preserves Child's referential identity across re-renders
const Child = $Child.unpack()
return <Child />
}
})

The magic is in useAssembleComponent:

  1. Creates the component once: The actual component reference is created on first render and stored.

  2. Updates flow through an internal store: When supplies change, the library uses a WeakMap-based store and useSyncExternalStore to propagate updates.

  3. Only affected components re-render: The library tracks which components depend on which resources. When $$theme changes, only components that actually consume $$theme get notified—exactly like React Context.

  4. Component identity is preserved: React sees the same component reference across renders, so it can properly reconcile and preserve state.

The useInit$ Pattern

Notice how the factory receives init$ instead of $ directly:

factory: (init$, $$) =>
function Parent() {
const $ = useInit$(init$)
// ...
}

This is intentional. The init$ object serves as a stable key that connects your React component to the reactive store. When a parent calls useAssembleComponent, it updates the store, and useInit$ subscribes to those updates via useSyncExternalStore.

Think of useInit$ as the typectx equivalent of useContext—but instead of accessing a single context, you get access to the entire $ supplies object.

Quick Migration Guide

Step 1: Update reassemble() calls

Find all uses of .reassemble() and replace them with $$(...).assemble():

// Before
$($$supplier).reassemble(index($$resource.pack(value)))

// After
$$($$supplier).assemble(index($$resource.pack(value)))

Step 2: For React, install the adapter

npm install @typectx/react

Step 3: Update your React client component factories

First, you need to call useInit$() at the top of every client Component factory. This is the only way @typectx/react knows the current supplier is a component supplier. It is also possible to add custom hooks to the supply chain. If this is your case, you also need to call useInit$() at the top of your custom hooks factories.

Then, you need to replace all $$().assemble calls with the useAssembleComponent(). If you dynamically assemble custom hooks, you can call useAssembleHook(), which is just an alias of useAssemblerComponent but reads nicer in that case.

// Before (v0.3 pattern)
const $$Parent = market.offer("Parent").asProduct({
suppliers: [$$data],
assemblers: [$$Child],
factory: ($, $$) =>
function Parent() {
const data = $($$data).unpack()
const [state, setState] = useState(0)

// ⚠️ BAD: Destroys Child state on every render
const Child = $$($$Child)
.assemble(index($$state.pack(state)))
.unpack()

return <Child />
}
})

// After (v0.4 with @typectx/react)
import { useInit$, useAssembleComponent } from "@typectx/react"

const $$Parent = market.offer("Parent").asProduct({
suppliers: [$$data],
assemblers: [$$Child],
factory: (init$, $$) =>
function Parent() {
const $ = useInit$(init$)
const data = $($$data).unpack()
const [state, setState] = useState(0)

// ✅ GOOD: Preserves Child referential integrity
const $Child = useAssembleComponent(
$$($$Child),
index($$state.pack(state))
)
const Child = $Child.unpack()

return <Child />
}
})

References

Check out the full @typectx/react documentation and the live example to see complex context propagation in action.

Wrapping Up

The v0.4 release represents a maturation of typectx's context propagation story. The $$ accessor provides a cleaner, more powerful primitive for working with suppliers inside factories. And @typectx/react proves that dependency injection and React's component model can work together beautifully—without sacrificing the state management and performance guarantees that make React great.


npm install typectx@latest @typectx/react