Repository: alloy-framework/alloy
Pull request #55 (2025-02-17 10:03:42)

Reactivity and control flow improvements
This PR fixes many issues related to reactivity, such as cleanup functions not being called, effects not being observed, and so forth. It also introduces an element cache during the rendering phase, which will reuse elements if they have not changed on a reactive re-render.

This foundation enables mapJoin to become lazy, where adding a new element to the array being mapped will not cause previous items to re-render. Currently, this is only true when appending items - any items after a changed element will be re-rendered. In the future this can be improved. In order to support this, **`mapJoin` takes a significant breaking change** - the array or map to be mapped must be wrapped in a callback - `mapJoin(someArray, ...)` becomes `mapJoin(() => someArray, ...)`. 

Additionally, this PR adds a `For` component which allows for using mapJoin ergonomically within the context of a JSX template. It also adds a `Switch` component for conditionally rendering elements. Combined, these enable moving significant amounts of logic into the template in a way that is still readable, and without requiring any complex gymnastics to leverage reactivity.

github-actions[bot]: <!--chronus-github-change-commenter-->
:x: There is undocummented changes. Run `chronus add` to add a changeset or [click here](https://github.com/bterlson/alloy/new/lazy-mapJoin?filename=.chronus/changes/lazy-mapJoin-2025-1-17-10-4-8.md&value=---%0A%23%20Change%20versionKind%20to%20one%20of%3A%20breaking%2C%20feature%2C%20fix%2C%20internal%0AchangeKind%3A%20fix%0Apackages%3A%0A%20%20-%20%22%40alloy-js%2Fcore%22%0A%20%20-%20%22%40alloy-js%2Fcsharp%22%0A%20%20-%20%22%40alloy-js%2Fjava%22%0A%20%20-%20%22%40alloy-js%2Fjson%22%0A%20%20-%20%22%40alloy-js%2Ftypescript%22%0A---%0A%0AReactivity%20and%20control%20flow%20improvements).

**The following packages have changes but are not documented.**
 - :x:`@alloy-js/core`
 - :x:`@alloy-js/csharp`
 - :x:`@alloy-js/java`
 - :x:`@alloy-js/json`
 - :x:`@alloy-js/typescript`

<details>
<summary>Show changes</summary>


</details>

pkg-pr-new[bot]: 
[Open in Stackblitz](https://pkg.pr.new/template/a6d9958d-43e5-41a1-a074-cf23c1c44d86)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@55
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@55
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/37329010978"><code>76c0c32</code></a>_


Patch:
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-12-32-41.md b/.chronus/changes/lazy-mapJoin-2025-1-17-12-32-41.md
new file mode 100644
index 0000000..16ce403
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-12-32-41.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+Export missing STC components.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-50-55.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-50-55.md
new file mode 100644
index 0000000..53716f3
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-50-55.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add `For` component to iterate over a provided array and render a component for each element.
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-51-42.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-51-42.md
new file mode 100644
index 0000000..25940fe
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-51-42.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add `Switch` component to conditionally render content.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-52-19.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-52-19.md
new file mode 100644
index 0000000..fa09593
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-52-19.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add support to the binder for deleting symbols.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-53-18.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-53-18.md
new file mode 100644
index 0000000..c2d3b3b
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-53-18.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+The `Declaration` component will now delete its symbol when its removed from the tree.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-54-35.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-54-35.md
new file mode 100644
index 0000000..1d572ed
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-54-35.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+Properly handle reactive updates in the children of a context Provider.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-57-34.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-57-34.md
new file mode 100644
index 0000000..a112b3f
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-57-34.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Don't re-render unchanged elements when doing reactive updates.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-2-59-12.md b/.chronus/changes/lazy-mapJoin-2025-1-17-2-59-12.md
new file mode 100644
index 0000000..ed52598
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-2-59-12.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+Fix cleanup of root contexts when cleanup is invoked from another context.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-1-54.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-1-54.md
new file mode 100644
index 0000000..997e4df
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-1-54.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add `createCustomContext` to allow establishing a custom reactive context during render.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-10-52.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-10-52.md
new file mode 100644
index 0000000..2439369
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-10-52.md
@@ -0,0 +1,7 @@
+---
+changeKind: internal
+packages:
+  - "@alloy-js/csharp"
+---
+
+Take mapJoin change.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-11-27.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-11-27.md
new file mode 100644
index 0000000..87d5ebf
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-11-27.md
@@ -0,0 +1,7 @@
+---
+changeKind: internal
+packages:
+  - "@alloy-js/java"
+---
+
+Take mapJoin change
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-12-5.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-12-5.md
new file mode 100644
index 0000000..e208340
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-12-5.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/json"
+---
+
+Objects and arrays now properly handle reactive JS values.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-13-31.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-13-31.md
new file mode 100644
index 0000000..8e62a72
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-13-31.md
@@ -0,0 +1,7 @@
+---
+changeKind: internal
+packages:
+  - "@alloy-js/typescript"
+---
+
+Take mapJoin change.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-7-28.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-7-28.md
new file mode 100644
index 0000000..bc3529e
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-7-28.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/core"
+---
+
+`mapJoin` now properly handles reactive sources. To support this, the array source must be wrapped in a callback. For example, `mapJoin(someArray, ...)` needs to be updated to `mapJoin(() => someArray, ...)`. The return value of `mapJoin` is now a memo rather than an array.
\ No newline at end of file
diff --git a/.chronus/changes/lazy-mapJoin-2025-1-17-3-8-24.md b/.chronus/changes/lazy-mapJoin-2025-1-17-3-8-24.md
new file mode 100644
index 0000000..57a874e
--- /dev/null
+++ b/.chronus/changes/lazy-mapJoin-2025-1-17-3-8-24.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add `findKeyedChildren` which finds multiple children with the given key.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index a849b3a..113b922 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -265,6 +265,12 @@ export interface Binder {
    */
   createSymbol<T extends OutputSymbol>(args: CreateSymbolOptions<T>): T;
 
+  /**
+   * Delete the given symbol. The symbol will be removed from its parent's
+   * scope. Any resolutions to this symbol will become undefined.
+   */
+  deleteSymbol(symbol: OutputSymbol): void;
+
   /**
    * Instantiate the static members of a symbol into the instance members of
    * another symbol.
@@ -415,6 +421,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
   const binder: Binder = {
     createScope,
     createSymbol,
+    deleteSymbol,
     resolveDeclarationByKey,
     addStaticMembersToSymbol,
     addInstanceMembersToSymbol,
@@ -584,6 +591,18 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     return symbol;
   }
 
+  function deleteSymbol(symbol: OutputSymbol) {
+    symbol.scope.symbols.delete(symbol);
+
+    if (!refkey) {
+      return;
+    }
+
+    const resolution = waitingDeclarations.get(symbol.refkey);
+    if (!resolution) return;
+    resolution.value = undefined;
+  }
+
   function instantiateSymbolInto(source: OutputSymbol, target: OutputSymbol) {
     if (~source.flags & OutputSymbolFlags.InstanceMemberContainer) {
       throw new Error("Can only instantiate symbols with instance members");
@@ -800,12 +819,14 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       const refkey = symbol.refkey;
       if (!refkey) return;
 
+      // notify those waiting for this refkey
       knownDeclarations.set(refkey, symbol);
       if (waitingDeclarations.has(refkey)) {
         const signal = waitingDeclarations.get(refkey)!;
         signal.value = symbol;
       }
 
+      // notify those waiting for this symbol name
       const waitingScope = waitingSymbolNames.get(symbol.scope);
       if (waitingScope) {
         const waitingName = waitingScope.get(symbol.name);
diff --git a/packages/core/src/components/Declaration.tsx b/packages/core/src/components/Declaration.tsx
index f49d320..11f5ea4 100644
--- a/packages/core/src/components/Declaration.tsx
+++ b/packages/core/src/components/Declaration.tsx
@@ -2,7 +2,7 @@ import { OutputSymbol } from "../binder.js";
 import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
 import { DeclarationContext } from "../context/declaration.js";
-import { Children } from "../jsx-runtime.js";
+import { Children, onCleanup } from "../jsx-runtime.js";
 import { Refkey, refkey } from "../refkey.js";
 
 export interface DeclarationProps {
@@ -45,6 +45,10 @@ export function Declaration(props: DeclarationProps) {
       name: props.name!,
       refkey: rk,
     });
+
+    onCleanup(() => {
+      binder.deleteSymbol(declaration!);
+    });
   }
 
   return <DeclarationContext.Provider value={declaration}>
diff --git a/packages/core/src/components/For.tsx b/packages/core/src/components/For.tsx
new file mode 100644
index 0000000..be8e991
--- /dev/null
+++ b/packages/core/src/components/For.tsx
@@ -0,0 +1,78 @@
+import { Children, memo } from "@alloy-js/core/jsx-runtime";
+import { isRef, Ref } from "@vue/reactivity";
+import { JoinOptions, mapJoin } from "../utils.js";
+
+export type UnwrapMaybeRef<T> = T extends Ref<infer U> ? U : T;
+
+export interface ForProps<T extends any[] | Ref<any[]>, U extends Children> {
+  /**
+   * The array to iterate over.
+   */
+  each: T;
+
+  /**
+   * The string to join the items with. By default, this is a line break.
+   */
+  joiner?: string;
+
+  /**
+   * The string to end the items with. Only emitted when there is more than one
+   * item.
+   */
+  ender?: string;
+
+  /**
+   * A function to call for each item.
+   */
+  children: (item: UnwrapMaybeRef<T>[number], index: number) => U;
+}
+
+/**
+ * The For component iterates over the provided array and invokes the child
+ * callback for each item. It can optionally be provided with a `joiner` which
+ * is placed between each item, and an `ender` which is placed after the last
+ * item when there is at least one item.
+ *
+ * @example
+ *
+ * ```tsx
+ * const items = ["apple", "pear", "plum"];
+ * return <For each={items}>
+ *   {(item) => <>Fruit: {item}</>}
+ * </For>
+ * ```
+ *
+ * @remarks
+ *
+ * When the `each` prop is a reactive (e.g. a reactive array, or ref to an
+ * array), `For` will automatically update when the array changes. When doing
+ * so, it will attempt to avoid re-rendering items which have not changed. For
+ * example, when appending an item to a reactive array, existing items will not
+ * be re-rendered. Note that presently the implementation is fairly simple -
+ * when making modifications to the middle of an array it likely that every
+ * element after the modification will be rerendered.
+ *
+ * @see {@link (mapJoin:1)} for mapping arrays to elements outside of JSX templates.
+ */
+export function For<T extends any[] | Ref<any[]>, U extends Children>(
+  props: ForProps<T, U>,
+) {
+  const cb = props.children;
+  const options: JoinOptions = {};
+  if (Object.hasOwn(props, "joiner")) {
+    options.joiner = props.joiner;
+  }
+
+  if (Object.hasOwn(props, "ender")) {
+    options.ender = props.ender;
+  }
+  return memo(() => {
+    const maybeRef = props.each;
+
+    return (mapJoin as any)(
+      () => (isRef(maybeRef) ? maybeRef.value : maybeRef),
+      cb,
+      options,
+    );
+  });
+}
diff --git a/packages/core/src/components/Switch.tsx b/packages/core/src/components/Switch.tsx
new file mode 100644
index 0000000..6b172bb
--- /dev/null
+++ b/packages/core/src/components/Switch.tsx
@@ -0,0 +1,62 @@
+import { Children, memo, taggedComponent } from "@alloy-js/core/jsx-runtime";
+import { childrenArray, findKeyedChildren } from "../utils.js";
+
+export interface SwitchProps {
+  children: Children;
+}
+
+/**
+ * Conditionally render blocks of content based on the `when` prop of nested
+ * {@link Match} components.
+ *
+ * @example
+ *
+ * ```tsx
+ * <Switch>
+ *   <Match when={someCondition}>
+ *     <div>Condition met!</div>
+ *   </Match>
+ *   <Match else>
+ *     <div>Condition not met!</div>
+ *   </Match>
+ * </Switch>
+ * ```
+ */
+export function Switch(props: SwitchProps) {
+  const children = childrenArray(() => props.children);
+  const matches = findKeyedChildren(children, matchTag);
+
+  return memo(() => {
+    for (const match of matches) {
+      if (match.props.when || match.props.else) {
+        return match.props.children;
+      }
+    }
+
+    return undefined;
+  });
+}
+
+export interface MatchProps {
+  /**
+   * Condition under which the children of this element will be rendered.
+   */
+  when?: boolean;
+
+  /**
+   * If no `when` is matched, the children of this element will be rendered.
+   * The `else` match must be placed last.
+   */
+  else?: boolean;
+  children: Children;
+}
+
+export const matchTag = Symbol();
+
+/**
+ * The Match component is used inside of a {@link Switch} component to
+ * define conditionally rendered blocks of content.
+ */
+export const Match = taggedComponent(matchTag, (props: MatchProps) => {
+  return () => (props.when ? props.children : undefined);
+});
diff --git a/packages/core/src/components/index.tsx b/packages/core/src/components/index.tsx
index d5710c1..ffae13f 100644
--- a/packages/core/src/components/index.tsx
+++ b/packages/core/src/components/index.tsx
@@ -1,4 +1,5 @@
 export * from "./Declaration.js";
+export * from "./For.js";
 export * from "./Indent.js";
 export * from "./MemberDeclaration.jsx";
 export * from "./MemberName.jsx";
@@ -8,3 +9,4 @@ export * from "./Output.js";
 export * from "./Scope.js";
 export * from "./SourceDirectory.js";
 export * from "./SourceFile.js";
+export * from "./Switch.jsx";
diff --git a/packages/core/src/components/stc/index.ts b/packages/core/src/components/stc/index.ts
index a6fc872..2f740e5 100644
--- a/packages/core/src/components/stc/index.ts
+++ b/packages/core/src/components/stc/index.ts
@@ -7,3 +7,9 @@ export const Output = stc(base.Output);
 export const Scope = stc(base.Scope);
 export const SourceDirectory = stc(base.SourceDirectory);
 export const SourceFile = stc(base.SourceFile);
+export const Switch = stc(base.Switch);
+export const For = stc(base.For);
+export const MemberScope = stc(base.MemberScope);
+export const MemberName = stc(base.MemberName);
+export const MemberDeclaration = stc(base.MemberDeclaration);
+export const Name = stc(base.Name);
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
index d3bf6e8..12e59f7 100644
--- a/packages/core/src/context.ts
+++ b/packages/core/src/context.ts
@@ -4,7 +4,6 @@ import {
   ComponentDefinition,
   effect,
   getContext,
-  untrack,
 } from "./jsx-runtime.js";
 
 export interface ComponentContext<T> {
@@ -49,8 +48,8 @@ export function createContext<T = unknown>(
       const rendered = shallowRef();
       effect(() => {
         context!.context![id] = props.value;
-        rendered.value = untrack(() => props.children);
-      });
+        rendered.value = () => props.children;
+      }, undefined);
 
       return rendered.value;
     },
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index 427791b..d12cabb 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -9,6 +9,7 @@ import {
   effect as vueEffect,
 } from "@vue/reactivity";
 import { Refkey } from "./refkey.js";
+import { RenderTextTree } from "./render.js";
 
 if ((globalThis as any).ALLOY) {
   throw new Error(
@@ -31,6 +32,11 @@ export interface Context {
   // store random info about the node
   meta?: Record<string, any>;
 
+  /**
+   * A cache of RenderTextTree nodes created within this context,
+   * indexed by the component or function which created them.
+   */
+  elementCache: ElementCache;
   /**
    * When this context was created by a component, this will
    * be the component that created it.
@@ -43,21 +49,35 @@ export function getContext() {
   return globalContext;
 }
 
-export function root<T>(
-  fn: (d: Disposable) => T,
-  componentOwner?: ComponentCreator<any>,
-): T {
-  globalContext = {
-    componentOwner,
+export function getElementCache() {
+  return getContext()!.elementCache;
+}
+
+export type ElementCacheKey =
+  | ComponentCreator
+  | (() => unknown)
+  | CustomContext;
+export type ElementCache = Map<ElementCacheKey, RenderTextTree>;
+
+export interface RootOptions {
+  componentOwner?: ComponentCreator<any>;
+}
+
+export function root<T>(fn: (d: Disposable) => T, options?: RootOptions): T {
+  const context: Context = {
+    componentOwner: options?.componentOwner,
     disposables: [],
     owner: globalContext,
     context: {},
+    elementCache: new Map(),
   };
+
+  globalContext = context;
   let ret;
   try {
     ret = untrack(() =>
       fn(() => {
-        for (const d of globalContext!.disposables) {
+        for (const d of context!.disposables) {
           d();
         }
       }),
@@ -83,28 +103,31 @@ export function memo<T>(fn: () => T, equal?: boolean): () => T {
     // eslint-disable-next-line @typescript-eslint/no-unused-expressions
     (!equal || prev !== res) && (o.value = res);
     return res;
-  });
+  }, undefined as T);
   return () => o.value;
 }
 
 export function effect<T>(fn: (prev?: T) => T, current?: T) {
-  const context = {
-    src: "effect",
+  const context: Context = {
     context: {},
     disposables: [] as (() => void)[],
     owner: globalContext,
-  } as any;
+    elementCache: new Map(),
+  };
 
   const cleanupFn = (final: boolean) => {
     const d = context.disposables;
     context.disposables = [];
     for (let k = 0, len = d.length; k < len; k++) d[k]();
+
     // eslint-disable-next-line @typescript-eslint/no-unused-expressions
     final && stop(c);
   };
 
+  onCleanup(() => cleanupFn(true));
   const c = vueEffect(() => {
     cleanupFn(false);
+
     const oldContext = globalContext;
     globalContext = context;
     try {
@@ -112,17 +135,64 @@ export function effect<T>(fn: (prev?: T) => T, current?: T) {
     } finally {
       globalContext = oldContext;
     }
-  });
-
-  cleanup(() => cleanupFn(true));
+  }, {});
 }
 
-export function cleanup(fn: Disposable) {
+/**
+ * Register a cleanup function which is called when the current reactive scope
+ * is recalculated or disposed. This is useful to clean up any side effects
+ * created in the reactive scope.
+ *
+ * @remarks
+ *
+ * When onCleanup is called inside a component definition, the provided function
+ * is called when the component is removed from the tree. This can be useful to
+ * clean up any side effects created as a result of rendering the component. For
+ * example, if rendering a component creates a symbol, `onCleanup` can be used
+ * to remove the symbol when the component is removed from the tree.
+ *
+ * When onCleanup is called inside a memo or effect, the function is called when
+ * the effect is refreshed (e.g. when a memo or computed recalculates) or
+ * disposed (e.g. it is no longer needed because it is attached to a component
+ * which was removed).
+ */
+export function onCleanup(fn: Disposable) {
   if (globalContext != null) {
     globalContext.disposables.push(fn);
   }
 }
 
+/**
+ * Create a custom reactive context for the children returned by
+ * the provided context.
+ */
+export interface CustomContext {
+  [CUSTOM_CONTEXT_SYM]: true;
+  useCustomContext: (useCb: CustomContextChildrenCallback) => void;
+}
+
+export type CustomContextChildrenCallback = (child: Children) => void;
+const CUSTOM_CONTEXT_SYM = Symbol();
+
+export function createCustomContext(
+  useCallback: (useChildren: CustomContextChildrenCallback) => void,
+): CustomContext {
+  return {
+    [CUSTOM_CONTEXT_SYM]: true,
+    useCustomContext(useCb: (children: Children) => void): void {
+      useCallback(useCb);
+    },
+  };
+}
+
+export function isCustomContext(child: Children): child is CustomContext {
+  return (
+    typeof child === "object" &&
+    child !== null &&
+    Object.hasOwn(child, CUSTOM_CONTEXT_SYM)
+  );
+}
+
 export type Child =
   | string
   | boolean
@@ -133,7 +203,8 @@ export type Child =
   | (() => Children)
   | Child[]
   | Ref
-  | Refkey;
+  | Refkey
+  | CustomContext;
 
 export type Children = Child | Child[];
 export type Props = Record<string, any>;
diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts
index 502c54a..e43e5e8 100644
--- a/packages/core/src/render.ts
+++ b/packages/core/src/render.ts
@@ -7,9 +7,12 @@ import {
   Child,
   Children,
   Context,
+  CustomContext,
   effect,
   getContext,
+  getElementCache,
   isComponentCreator,
+  isCustomContext,
   popStack,
   printRenderStack,
   pushStack,
@@ -173,7 +176,7 @@ export type RenderTextTree = (string | RenderTextTree)[];
 
 function traceRender(phase: string, message: () => string) {
   return false;
-  // console.log(`[\x1b[34m${phase}\x1b[0m]: ${message}`);
+  //console.log(`[\x1b[34m${phase}\x1b[0m]: ${message()}`);
 }
 
 export function render(children: Children): OutputDirectory {
@@ -276,7 +279,7 @@ function renderWorker(
 
   const indent = useContext(IndentContext)!;
   if (Array.isArray(children)) {
-    for (const child of children) {
+    for (const child of (children as any).flat(Infinity)) {
       appendChild(node, child, indent, state);
     }
   } else {
@@ -302,39 +305,54 @@ function appendChild(
     const reindented = reindent(child, indentState.indentString);
     traceRender("appendChild:string", () => JSON.stringify(reindented));
     node.push(reindented);
-  } else if (isComponentCreator(child)) {
-    root(() => {
-      traceRender("appendChild:component", () => printChild(child));
-      if (child.component === Indent && state.newline) {
-        node.push(indentState.indent);
-      }
-      const componentRoot: RenderTextTree = [];
-      pushStack(child.component, child.props);
-      renderWorker(componentRoot, untrack(child), state);
-      popStack();
-      node.push(componentRoot);
-      traceRender("appendChild:component-done", () => printChild(child));
-    }, child);
-  } else if (typeof child === "function") {
-    traceRender("appendChild:memo", () => child.toString());
-    const index = node.length;
-    effect((prev: any) => {
-      traceRender("memoEffect:run", () => "");
-      let res = child();
-      while (typeof res === "function" && !isComponentCreator(res)) {
-        res = res();
-      }
-      const newNodes: RenderTextTree = [];
-      renderWorker(newNodes, res, state);
-      //node.splice(index, prev ? prev.length : 0, ...newNodes);
-      node[index] = newNodes;
-      return newNodes;
-    });
-    traceRender("appendChild:memo-done", () => "");
   } else {
-    traceRender("appendChild:array", () => dumpChildren(child));
-    renderWorker(node, child, state);
-    traceRender("appendChild:array-done", () => dumpChildren(child));
+    const cache = getElementCache();
+    if (cache.has(child as any)) {
+      traceRender("appendChild:cached", () => printChild(child));
+      node.push(cache.get(child as any)!);
+      return;
+    }
+    if (isCustomContext(child)) {
+      traceRender("appendChild:custom-context", () => printChild(child));
+      child.useCustomContext((children) => {
+        const newNode: RenderTextTree = [];
+        renderWorker(newNode, children, state);
+        node.push(newNode);
+        cache.set(child, newNode);
+      });
+    } else if (isComponentCreator(child)) {
+      effect(() => {
+        traceRender("appendChild:component", () => printChild(child));
+        if (child.component === Indent && state.newline) {
+          node.push(indentState.indent);
+        }
+        const componentRoot: RenderTextTree = [];
+        pushStack(child.component, child.props);
+        renderWorker(componentRoot, untrack(child), state);
+        popStack();
+        node.push(componentRoot);
+        cache.set(child, componentRoot);
+        traceRender("appendChild:component-done", () => printChild(child));
+      });
+    } else if (typeof child === "function") {
+      traceRender("appendChild:memo", () => child.toString());
+      const index = node.length;
+      effect(() => {
+        traceRender("memoEffect:run", () => "");
+        let res = child();
+        while (typeof res === "function" && !isComponentCreator(res)) {
+          res = res();
+        }
+        const newNodes: RenderTextTree = [];
+        renderWorker(newNodes, res, state);
+        node[index] = newNodes;
+        cache.set(child, newNodes);
+        return newNodes;
+      });
+      traceRender("appendChild:memo-done", () => "");
+    } else {
+      throw new Error("Unexpected child type");
+    }
   }
 }
 
@@ -342,7 +360,11 @@ function reindent(str: string, indent: string) {
   const lines = str.split("\n");
   return [lines[0], ...lines.slice(1).map((line) => indent + line)].join("\n");
 }
-type NormalizedChild = string | (() => Child | Children) | NormalizedChild[];
+type NormalizedChild =
+  | string
+  | (() => Child | Children)
+  | CustomContext
+  | NormalizedChild[];
 
 function normalizeChild(child: Child): NormalizedChild {
   if (Array.isArray(child)) {
@@ -366,6 +388,8 @@ function normalizeChild(child: Child): NormalizedChild {
 
       return sfContext.reference({ refkey: child });
     };
+  } else if (isCustomContext(child)) {
+    return child;
   } else {
     return String(child);
   }
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 53e2859..e2a82b1 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -1,11 +1,18 @@
+import { toRaw } from "@vue/reactivity";
 import { code } from "./code.js";
 import {
   Child,
   Children,
   ComponentCreator,
   ComponentDefinition,
+  createCustomContext,
+  CustomContext,
+  Disposable,
   isComponentCreator,
   memo,
+  onCleanup,
+  root,
+  untrack,
 } from "./jsx-runtime.js";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { OutputDirectory, OutputFile, render } from "./render.js";
@@ -40,10 +47,10 @@ const defaultJoinOptions: JoinOptions = {
  *
  */
 export function mapJoin<T, U, V>(
-  src: Map<T, U>,
+  src: () => Map<T, U>,
   cb: (key: T, value: U) => V,
   options?: JoinOptions,
-): (V | string)[];
+): () => (V | string | undefined | CustomContext)[];
 /**
  * Map a array or iterator to another array using a mapper and place a joiner
  * between each element. Defaults to joining with a newline.
@@ -55,47 +62,115 @@ export function mapJoin<T, U, V>(
  * @returns The mapped and joined array.
  */
 export function mapJoin<T, V>(
-  src: T[] | IterableIterator<T>,
+  src: () => T[] | IterableIterator<T>,
   cb: (value: T) => V,
   options?: JoinOptions,
-): (V | string)[];
+): () => (V | string | undefined | CustomContext)[];
 export function mapJoin<T, U, V>(
-  src: Map<T, U> | T[] | Iterable<T>,
+  src: () => Map<T, U> | T[] | Iterable<T>,
   cb: (key: T, value?: U) => V,
   rawOptions: JoinOptions = {},
-): (V | string)[] {
+): () => (V | string | undefined | CustomContext)[] {
   const options = { ...defaultJoinOptions, ...rawOptions };
-  const ender = options.ender === true ? options.joiner : options.ender;
+  const ender =
+    options.ender === true ? options.joiner : options.ender || undefined;
+  const currentItems: (T | [T, U])[] = [];
+  const disposables: Disposable[] = [];
+  const mapped: (V | string | undefined | CustomContext)[] = [];
+  let previousItemsLen = 0;
 
-  const mapped: (V | string)[] = [];
-  if (typeof (src as any).next === "function") {
-    src = Array.from(src as Iterable<T>);
-  }
+  onCleanup(() => {
+    for (const d of disposables) d();
+  });
+
+  return () => {
+    const itemsSource = src();
+    // need to unpack reactives for branding checks
+    const itemsSourceRaw = toRaw(itemsSource);
+    const items =
+      Array.isArray(itemsSourceRaw) ? (itemsSource as T[]) : [...itemsSource];
+    // this is important to access here in reactive context so we are
+    // notified of new items from reactives.
+    const itemsLen = items.length;
+    const compare: any = getCompareFunction(itemsSource);
+    const mapper: any = getMapperFunction(itemsSource);
 
-  if (Array.isArray(src)) {
-    for (const [index, item] of src.entries()) {
-      mapped.push(cb(item));
-      if (index !== src.length - 1) {
-        mapped.push(options.joiner!);
+    return untrack(() => {
+      let startIndex = 0;
+      for (
+        ;
+        startIndex < itemsLen && startIndex < currentItems.length;
+        startIndex++
+      ) {
+        if (!compare(items[startIndex], currentItems[startIndex])) {
+          break;
+        }
       }
-    }
-    if (src.length > 0 && ender) {
-      mapped.push(ender);
-    }
-  } else {
-    const entries = [...(src as Map<T, U>).entries()];
-    for (const [index, [key, value]] of entries.entries()) {
-      mapped.push(cb(key, value));
-      if (index !== entries.length - 1) {
-        mapped.push(options.joiner!);
+
+      if (startIndex > 0 && startIndex < itemsLen) {
+        // need to update the previous joiner (might be ender or absent)
+        mapped[startIndex * 2 - 1] = options.joiner;
       }
-    }
-    if (entries.length > 0 && ender) {
-      mapped.push(ender);
-    }
+      for (; startIndex < itemsLen; startIndex++) {
+        currentItems[startIndex] = items[startIndex];
+        if (disposables[startIndex]) {
+          disposables[startIndex]();
+        }
+        const cleanupIndex = startIndex;
+        mapped[startIndex * 2] = createCustomContext((cb) => {
+          return root((disposer) => {
+            disposables[cleanupIndex] = disposer;
+            disposer();
+            cb(mapper(items[cleanupIndex]));
+          });
+        });
+
+        mapped[startIndex * 2 + 1] =
+          startIndex < items.length - 1 ? options.joiner : ender;
+      }
+
+      mapped.length = startIndex * 2;
+      mapped[mapped.length - 1] = ender;
+      for (; startIndex < previousItemsLen; startIndex++) {
+        disposables[startIndex]?.();
+      }
+
+      previousItemsLen = itemsLen;
+
+      return mapped;
+    });
+  };
+
+  function getCompareFunction(itemsSource: Map<T, U> | T[] | Iterable<T>) {
+    return Array.isArray(itemsSource) || isIterable(itemsSource) ?
+        compareArray
+      : compareMap;
   }
 
-  return mapped;
+  function getMapperFunction(itemsSource: Map<T, U> | T[] | Iterable<T>) {
+    return Array.isArray(itemsSource) || isIterable(itemsSource) ?
+        mapArray
+      : mapMap;
+  }
+  function compareArray(elem1: T, elem2: T) {
+    return elem1 === elem2;
+  }
+
+  function compareMap(record1: [T, U], record2: [T, U]) {
+    return record1[0] === record2[0] && record1[1] === record2[1];
+  }
+
+  function mapArray(item: T) {
+    return cb(item);
+  }
+
+  function mapMap(item: [T, U]) {
+    return cb(item[0], item[1]);
+  }
+
+  function isIterable<T>(x: unknown): x is Iterable<T> {
+    return typeof (x as any).next === "function";
+  }
 }
 
 /**
@@ -171,6 +246,16 @@ export function findKeyedChild(children: Child[], tag: symbol) {
   return null;
 }
 
+export function findKeyedChildren(children: Child[], tag: symbol) {
+  const keyedChildren: ComponentCreator[] = [];
+  for (const child of children) {
+    if (isKeyedChild(child) && child.tag === tag) {
+      keyedChildren.push(child);
+    }
+  }
+  return keyedChildren;
+}
+
 export function findUnkeyedChildren(children: Child[]) {
   return children.filter((child) => !isKeyedChild(child));
 }
diff --git a/packages/csharp/src/components/Parameters.tsx b/packages/csharp/src/components/Parameters.tsx
index c577cda..1fbbec1 100644
--- a/packages/csharp/src/components/Parameters.tsx
+++ b/packages/csharp/src/components/Parameters.tsx
@@ -41,12 +41,8 @@ export interface ParametersProps {
 }
 
 // a collection of parameters
-export function Parameters(props: ParametersProps): Array<core.Child | string> {
-  return core.mapJoin(
-    props.parameters,
-    (param) => {
-      return <Parameter {...param} />;
-    },
-    { joiner: ", " },
-  );
+export function Parameters(props: ParametersProps) {
+  return <core.For each={props.parameters} joiner={", "}>
+    {(param) => <Parameter {...param} />}
+  </core.For>;
 }
diff --git a/packages/csharp/src/components/UsingDirective.tsx b/packages/csharp/src/components/UsingDirective.tsx
index f4fd398..290324a 100644
--- a/packages/csharp/src/components/UsingDirective.tsx
+++ b/packages/csharp/src/components/UsingDirective.tsx
@@ -6,14 +6,11 @@ export interface UsingDirectiveProps {
 
 // one ore more C# using directives
 export function UsingDirective(props: UsingDirectiveProps) {
-  // we need core.memo here so that the contents are in a reactive context.
-  // the values for namespaces are reactive thus we need to observe any changes.
-  return core.memo(() => {
-    props.namespaces.sort();
-    return core
-      .mapJoin(props.namespaces, (namespace) => {
-        return `using ${namespace};`;
-      })
-      .join("");
+  const sortedNamespaces = core.computed(() => {
+    return props.namespaces.sort();
   });
+
+  return <core.For each={sortedNamespaces}>
+    {namespace => `using ${namespace};`}
+  </core.For>;
 }
diff --git a/packages/docs/scripts/components/SeeAlso.ts b/packages/docs/scripts/components/SeeAlso.ts
index 7575195..f984a4e 100644
--- a/packages/docs/scripts/components/SeeAlso.ts
+++ b/packages/docs/scripts/components/SeeAlso.ts
@@ -54,21 +54,27 @@ export function SeeAlso(props: SeeAlsoProps) {
   const contextsProvidedList =
     contextsProvided.length > 0 &&
     MdxSection({ title: "Contexts provided", level: 2 }).children(
-      mapJoin(contextsProvided, (seeBlock) => {
-        return code`
+      mapJoin(
+        () => contextsProvided,
+        (seeBlock) => {
+          return code`
           * ${TsDoc({ node: seeBlock, context: props.type })}
         `;
-      }),
+        },
+      ),
     );
 
   const seeAlsoList =
     seeBlocks.length > 0 &&
     MdxSection({ title: "See also", level: 3 }).children(
-      mapJoin(seeBlocks, (seeBlock) => {
-        return code`
+      mapJoin(
+        () => seeBlocks,
+        (seeBlock) => {
+          return code`
           * ${TsDoc({ node: seeBlock, context: props.type })}
         `;
-      }),
+        },
+      ),
     );
 
   return [contextsProvidedList, seeAlsoList];
diff --git a/packages/docs/scripts/components/TsDoc.ts b/packages/docs/scripts/components/TsDoc.ts
index a58f805..32f824c 100644
--- a/packages/docs/scripts/components/TsDoc.ts
+++ b/packages/docs/scripts/components/TsDoc.ts
@@ -106,9 +106,13 @@ export interface TsDocSectionProps {
 }
 
 export function TsDocSection(props: TsDocSectionProps) {
-  return mapJoin(props.node.nodes as DocNode[], (node) => stc.TsDoc({ node }), {
-    joiner: "\n\n",
-  });
+  return mapJoin(
+    () => props.node.nodes as DocNode[],
+    (node) => stc.TsDoc({ node }),
+    {
+      joiner: "\n\n",
+    },
+  );
 }
 
 export interface TsDocLinkTagProps {
diff --git a/packages/docs/scripts/components/component/ComponentDoc.ts b/packages/docs/scripts/components/component/ComponentDoc.ts
index b07838b..7a76e5c 100644
--- a/packages/docs/scripts/components/component/ComponentDoc.ts
+++ b/packages/docs/scripts/components/component/ComponentDoc.ts
@@ -26,14 +26,16 @@ export function ComponentDoc(props: ComponentDocProps) {
     overloadBlocks = [ComponentSignature({ component: props.component })];
   } else {
     let index = 1;
-    if (componentProps.length > 1) debugger;
-    overloadBlocks = mapJoin(componentProps, (iface) => {
-      return MdxSection({ title: "Overload " + index++, level: 2 }).children(
-        Summary({ type: iface }),
-        ComponentSignature({ component: props.component, propsType: iface }),
-        ComponentProps({ propType: iface }),
-      );
-    });
+    overloadBlocks = mapJoin(
+      () => componentProps,
+      (iface) => {
+        return MdxSection({ title: "Overload " + index++, level: 2 }).children(
+          Summary({ type: iface }),
+          ComponentSignature({ component: props.component, propsType: iface }),
+          ComponentProps({ propType: iface }),
+        );
+      },
+    );
   }
 
   return DocSourceFile({
diff --git a/packages/docs/scripts/components/function/FunctionParameters.ts b/packages/docs/scripts/components/function/FunctionParameters.ts
index 90ab795..955a358 100644
--- a/packages/docs/scripts/components/function/FunctionParameters.ts
+++ b/packages/docs/scripts/components/function/FunctionParameters.ts
@@ -8,7 +8,7 @@ export interface FunctionParametersProps {
 
 export function FunctionParameters(props: FunctionParametersProps) {
   const params = mapJoin(
-    props.fn.parameters as Parameter[],
+    () => props.fn.parameters as Parameter[],
     (param) => code`
       <tr>
         <td style="text-align: right;font-weight: bold;">${param.name}</td>
diff --git a/packages/docs/scripts/components/type/TypeMembers.ts b/packages/docs/scripts/components/type/TypeMembers.ts
index 9e81ef4..771040c 100644
--- a/packages/docs/scripts/components/type/TypeMembers.ts
+++ b/packages/docs/scripts/components/type/TypeMembers.ts
@@ -19,7 +19,7 @@ export function TypeMembers(props: TypeMembersProps) {
 
   if (props.type.extendsTypes.length > 0) {
     const extendsItems = mapJoin(
-      props.type.extendsTypes as HeritageType[],
+      () => props.type.extendsTypes as HeritageType[],
       (type) => Excerpt({ excerpt: type.excerpt, context: props.type }),
       { joiner: "," },
     );
diff --git a/packages/java/src/arguments.ts b/packages/java/src/arguments.ts
index df2155a..eb8f89d 100644
--- a/packages/java/src/arguments.ts
+++ b/packages/java/src/arguments.ts
@@ -8,7 +8,11 @@ import { Children, mapJoin } from "@alloy-js/core";
  */
 export function collectArguments(args: Children): Children {
   return Array.isArray(args) ?
-      mapJoin(args, (val) => val, { joiner: ", " })
+      mapJoin(
+        () => args,
+        (val) => val,
+        { joiner: ", " },
+      )
     : args;
 }
 
@@ -21,8 +25,8 @@ export function collectNamedArguments(
   args: Record<string, Children>,
 ): Children {
   return mapJoin(
-    new Map(Object.entries(args)),
-    (key, value) => {
+    () => Object.entries(args),
+    ([key, value]) => {
       return [key, " = ", value];
     },
     { joiner: ", " },
diff --git a/packages/java/src/components/ImportStatement.tsx b/packages/java/src/components/ImportStatement.tsx
index c534503..d5c5ee9 100644
--- a/packages/java/src/components/ImportStatement.tsx
+++ b/packages/java/src/components/ImportStatement.tsx
@@ -12,8 +12,10 @@ export interface ImportStatementsProps {
 
 export function ImportStatements(props: ImportStatementsProps) {
   return memo(() =>
-    mapJoin(props.imports, (importProp) =>
-      () => <ImportStatement {...importProp} />));
+    mapJoin(
+      () => props.imports,
+      (importProp) => () => <ImportStatement {...importProp} />,
+    ));
 }
 
 export function ImportStatement(props: ImportSymbol) {
diff --git a/packages/java/src/components/Parameters.tsx b/packages/java/src/components/Parameters.tsx
index 0ff8297..da63a75 100644
--- a/packages/java/src/components/Parameters.tsx
+++ b/packages/java/src/components/Parameters.tsx
@@ -12,8 +12,8 @@ export interface ParametersProps {
 export function Parameters(props: ParametersProps) {
   const { parameters = {} } = props;
   return mapJoin(
-    new Map(Object.entries(parameters)),
-    (name, type) => {
+    () => Object.entries(parameters),
+    ([name, type]) => {
       return [type, " ", name];
     },
     { joiner: ", " },
diff --git a/packages/java/src/generics.ts b/packages/java/src/generics.ts
index d1a581c..c0edf71 100644
--- a/packages/java/src/generics.ts
+++ b/packages/java/src/generics.ts
@@ -20,8 +20,8 @@ export interface GenericTypes {
  */
 export function collectGenerics(generics: Record<string, Children>) {
   const genericTypes = mapJoin(
-    new Map(Object.entries(generics)),
-    (key, value) => {
+    () => Object.entries(generics),
+    ([key, value]) => {
       const extendsExpression = value ? code` extends ${value}` : "";
       return [key, extendsExpression];
     },
@@ -41,7 +41,7 @@ export function collectGenerics(generics: Record<string, Children>) {
  */
 export function passGenerics(generics: (Child | Record<"?", Children>)[]) {
   return mapJoin(
-    generics,
+    () => generics,
     (val) => {
       if (typeof val === "object" && val) {
         const key = Object.keys(val)[0];
diff --git a/packages/json/src/components/JsonArray.tsx b/packages/json/src/components/JsonArray.tsx
index 42758d3..af44635 100644
--- a/packages/json/src/components/JsonArray.tsx
+++ b/packages/json/src/components/JsonArray.tsx
@@ -1,10 +1,13 @@
 import {
   Children,
-  mapJoin,
+  For,
+  Match,
   MemberDeclaration,
   MemberScope,
+  onCleanup,
   OutputSymbolFlags,
   Refkey,
+  Switch,
   useMemberDeclaration,
   useMemberScope,
 } from "@alloy-js/core";
@@ -71,47 +74,48 @@ export function JsonArray(props: JsonArrayProps) {
 
   const jsValue = props.jsValue ?? [];
 
-  if (jsValue.length === 0) {
-    return "[]";
-  }
-
-  const elements = mapJoin(
-    jsValue,
-    (value) => {
-      return <JsonArrayElement><JsonValue jsValue={value} /></JsonArrayElement>;
-    },
-    { joiner: ",\n" },
-  );
-
-  let contents;
-  if (jsValue.length === 1) {
-    contents = <>[{elements}]</>;
-  } else {
-    contents = <>
-      [
-        {elements}
-      ]
-    </>;
-  }
-
   return <MemberScope owner={memberSymbol}>
-    {contents}
+    <Switch>
+      <Match when={jsValue.length === 0}>
+        []
+      </Match>
+      <Match when={jsValue.length === 1}>
+        [<JsonArrayElement jsValue={jsValue[0]} />]
+      </Match>
+      <Match else>
+        [
+          <For each={jsValue} joiner={",\n"}>
+            {(value) => <JsonArrayElement jsValue={value} />}
+          </For>
+        ]
+      </Match>
+    </Switch>
   </MemberScope>;
 }
 
-export interface JsonArrayElementProps {
+/**
+ * Create a JSON Array element by providing a JS value that will be serialized
+ * to JSON.
+ */
+export interface JsonArrayElementPropsWithJsValue {
+  jsValue: unknown;
+}
+
+/**
+ * Create a JSON Array element by providing children.
+ */
+export interface JsonArrayElementPropsWithChildren {
   children: Children;
 }
 
+export type JsonArrayElementProps =
+  | JsonArrayElementPropsWithJsValue
+  | JsonArrayElementPropsWithChildren;
+
 /**
  * Create a JSON Array element. This component should be used inside of a
- * {@link JsonArray} component.
- *
- * @remarks
- *
- * It does not add any syntax, but is required in
- * order to establish the correct scope for the array element, allowing
- * references to array elements to work properly.
+ * {@link JsonArray} component. Can either be provided `children`, or a JS
+ * value which will be serialized to JSON.
  *
  * @see {@link @alloy-js/core#(MemberDeclarationContext:variable)}
  */
@@ -132,7 +136,18 @@ export function JsonArrayElement(props: JsonArrayElementProps) {
     flags: OutputSymbolFlags.StaticMember,
   });
 
-  return <MemberDeclaration symbol={sym}>
-    {props.children}
-  </MemberDeclaration>;
+  onCleanup(() => {
+    sym.binder.deleteSymbol(sym);
+  });
+
+  function wrap(children: Children) {
+    return <MemberDeclaration symbol={sym}>
+      {children}
+    </MemberDeclaration>;
+  }
+  if (!("jsValue" in props)) {
+    return wrap(() => props.children);
+  } else {
+    return wrap(<JsonValue jsValue={props.jsValue} />);
+  }
 }
diff --git a/packages/json/src/components/JsonObject.tsx b/packages/json/src/components/JsonObject.tsx
index aae9d71..765d31b 100644
--- a/packages/json/src/components/JsonObject.tsx
+++ b/packages/json/src/components/JsonObject.tsx
@@ -1,9 +1,10 @@
 import {
   Children,
   computed,
-  mapJoin,
+  For,
   MemberDeclaration,
   MemberScope,
+  onCleanup,
   OutputSymbolFlags,
   Refkey,
   useMemberDeclaration,
@@ -72,32 +73,24 @@ export function JsonObject(props: JsonObjectProps) {
     </MemberScope>;
   }
 
-  const elements = computed(() => {
+  const entries = computed(() => {
     const jsValue = props.jsValue;
-    let properties: [string, unknown][];
     if (Array.isArray(jsValue)) {
-      properties = jsValue;
+      return jsValue;
     } else if (jsValue instanceof Map) {
-      properties = [...jsValue.entries()];
+      return [...jsValue.entries()];
     } else if (jsValue !== undefined) {
-      properties = globalThis.Object.entries(jsValue);
+      return globalThis.Object.entries(jsValue);
     } else {
-      properties = [];
+      return [];
     }
-    const elements = mapJoin(
-      properties,
-      ([name, value]) => {
-        return <JsonObjectProperty name={name} jsValue={value} />;
-      },
-      { joiner: ",\n" },
-    );
-
-    return elements;
   });
 
   return <MemberScope owner={memberSymbol}>
     {"{"}
-      {elements}
+      <For each={entries} joiner={",\n"}>
+        {([name, value], index) => <JsonObjectProperty name={name} jsValue={value} />}
+      </For>
     {"}"}
   </MemberScope>;
 }
@@ -136,6 +129,10 @@ export function JsonObjectProperty(props: ObjectPropertyProps) {
     flags: OutputSymbolFlags.StaticMember,
   });
 
+  onCleanup(() => {
+    sym.binder.deleteSymbol(sym);
+  });
+
   const wrap = (children: Children) => {
     return <MemberDeclaration symbol={sym}>
       "{props.name}": {children}
diff --git a/packages/typescript/src/components/ArrayExpression.tsx b/packages/typescript/src/components/ArrayExpression.tsx
index a9037ad..b9af1ce 100644
--- a/packages/typescript/src/components/ArrayExpression.tsx
+++ b/packages/typescript/src/components/ArrayExpression.tsx
@@ -12,17 +12,19 @@ export function ArrayExpression(props: ArrayExpressionProps) {
     let elements: any[] = [];
 
     if (jsValue) {
-      elements = mapJoin(
-        jsValue,
-        (value) => {
-          return <ValueExpression jsValue={value} />;
-        },
-        { joiner: ",\n" },
-      );
+      elements = [
+        mapJoin(
+          () => jsValue,
+          (value) => {
+            return <ValueExpression jsValue={value} />;
+          },
+          { joiner: ",\n" },
+        ),
+      ];
     }
 
     if (props.children) {
-      if (elements.length > 0) {
+      if (jsValue && jsValue.length > 0) {
         elements.push(",");
       }
       elements.push("\n", props.children);
diff --git a/packages/typescript/src/components/BarrelFile.tsx b/packages/typescript/src/components/BarrelFile.tsx
index e27fac9..c24453d 100644
--- a/packages/typescript/src/components/BarrelFile.tsx
+++ b/packages/typescript/src/components/BarrelFile.tsx
@@ -85,9 +85,12 @@ export function BarrelFile(props: BarrelFileProps) {
     );
 
     const allModules = [...sourceFiles, ...nestedBarrels];
-    return mapJoin(allModules, (module) => {
-      return <ExportStatement star from={module} />;
-    });
+    return mapJoin(
+      () => allModules,
+      (module) => {
+        return <ExportStatement star from={module} />;
+      },
+    );
   });
 
   return <SourceFile path={path} export={props.export}>
diff --git a/packages/typescript/src/components/EnumDeclaration.tsx b/packages/typescript/src/components/EnumDeclaration.tsx
index 1a2cc02..ab5be96 100644
--- a/packages/typescript/src/components/EnumDeclaration.tsx
+++ b/packages/typescript/src/components/EnumDeclaration.tsx
@@ -36,7 +36,7 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
   });
 
   const jsValueMembers = mapJoin(
-    Object.entries(props.jsValue ?? {}),
+    () => Object.entries(props.jsValue ?? {}),
     ([name, value]) => {
       return <EnumMember name={name} jsValue={value} />;
     },
@@ -46,7 +46,7 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
   return <CoreDeclaration symbol={sym}>
     {props.export ? "export " : ""}{props.default ? "default " : ""}enum <Name /> {"{"}
       <MemberScope owner={sym}>
-        {jsValueMembers}{jsValueMembers.length > 0 && props.children && ",\n"}{props.children}
+        {jsValueMembers}{jsValueMembers().length > 0 && props.children && ",\n"}{props.children}
       </MemberScope>
     {"}"}
   </CoreDeclaration>;
diff --git a/packages/typescript/src/components/FunctionCallExpression.tsx b/packages/typescript/src/components/FunctionCallExpression.tsx
index 6b878a5..51882e8 100644
--- a/packages/typescript/src/components/FunctionCallExpression.tsx
+++ b/packages/typescript/src/components/FunctionCallExpression.tsx
@@ -7,7 +7,11 @@ export interface FunctionCallExpressionProps {
 }
 export function FunctionCallExpression(props: FunctionCallExpressionProps) {
   const args = props.args && props.args.length ?
-    mapJoin(props.args, (arg) => arg, { joiner: ", " })
+    mapJoin(
+      () => props.args!,
+      (arg) => arg,
+      { joiner: ", " },
+    )
   : null;
   return <>
         <Reference refkey={props.refkey} />({args})
diff --git a/packages/typescript/src/components/FunctionDeclaration.tsx b/packages/typescript/src/components/FunctionDeclaration.tsx
index 48234c4..f77fed4 100644
--- a/packages/typescript/src/components/FunctionDeclaration.tsx
+++ b/packages/typescript/src/components/FunctionDeclaration.tsx
@@ -94,7 +94,7 @@ FunctionDeclaration.Parameters = taggedComponent(
       }
 
       value = mapJoin(
-        new Map(Object.entries(props.parameters)),
+        () => new Map(Object.entries(props.parameters!)),
         (key, value) => {
           const descriptor: ParameterDescriptor = isParameterDescriptor(value) ?
             value
diff --git a/packages/typescript/src/components/ImportStatement.tsx b/packages/typescript/src/components/ImportStatement.tsx
index f7eb9ce..ac54648 100644
--- a/packages/typescript/src/components/ImportStatement.tsx
+++ b/packages/typescript/src/components/ImportStatement.tsx
@@ -20,12 +20,9 @@ export interface ImportStatementsProps {
 export function ImportStatements(props: ImportStatementsProps) {
   const pkg = usePackage();
 
-  return memo(() => {
-    // todo: may be able to remove this (was just making sure to trigger
-    // reactivity based on props.record).
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    props.records.size;
-    return mapJoin(props.records, (module, importedSymbols) => {
+  return mapJoin(
+    () => props.records,
+    (module, importedSymbols) => {
       let targetPath: string;
 
       if (
@@ -60,8 +57,8 @@ export function ImportStatements(props: ImportStatementsProps) {
       }
 
       return <ImportStatement path={targetPath} symbols={importedSymbols} />;
-    });
-  });
+    },
+  );
 }
 
 export interface ImportStatementProps {
@@ -96,7 +93,7 @@ export function ImportStatement(props: ImportStatementProps) {
       parts.push("{ ");
       parts.push(
         mapJoin(
-          namedImportSymbols,
+          () => namedImportSymbols,
           (nis) => <ImportBinding importedSymbol={nis} />,
           { joiner: ", " },
         ),
diff --git a/packages/typescript/src/components/ObjectExpression.tsx b/packages/typescript/src/components/ObjectExpression.tsx
index a46a1e5..8e9aa62 100644
--- a/packages/typescript/src/components/ObjectExpression.tsx
+++ b/packages/typescript/src/components/ObjectExpression.tsx
@@ -28,7 +28,7 @@ export function ObjectExpression(props: ObjectExpressionProps) {
     assignmentSymbol.binder.addStaticMembersToSymbol(assignmentSymbol);
   }
 
-  const elements = computed(() => {
+  const jsValueProperties = computed(() => {
     const jsValue = props.jsValue;
     let properties: [string, unknown][];
     if (Array.isArray(jsValue)) {
@@ -40,13 +40,23 @@ export function ObjectExpression(props: ObjectExpressionProps) {
     } else {
       properties = [];
     }
-    const elements = mapJoin(
-      properties,
-      ([name, value]) => {
-        return <ObjectProperty name={name} jsValue={value} />;
-      },
-      { joiner: ",\n" },
-    );
+    return properties;
+  });
+
+  const elements = computed(() => {
+    const elements: Children[] = [];
+
+    if (jsValueProperties.value.length > 0) {
+      elements.push(
+        mapJoin(
+          () => jsValueProperties.value,
+          ([name, value]) => {
+            return <ObjectProperty name={name} jsValue={value} />;
+          },
+          { joiner: ",\n" },
+        ),
+      );
+    }
 
     if (props.children) {
       if (elements.length > 0) {
diff --git a/samples/client-emitter/src/components/Client.tsx b/samples/client-emitter/src/components/Client.tsx
index aefddca..b5d16fb 100644
--- a/samples/client-emitter/src/components/Client.tsx
+++ b/samples/client-emitter/src/components/Client.tsx
@@ -1,15 +1,15 @@
-import { Children, mapJoin, refkey } from "@alloy-js/core";
+import { Children, For, refkey } from "@alloy-js/core";
 import * as ts from "@alloy-js/typescript";
 import { useApi } from "../context/api.js";
 import { ClientMethod } from "./ClientMethod.jsx";
 
 export function Client(): Children {
   const schema = useApi().schema;
-  const operations = schema.operations;
-  const methods = mapJoin(operations, (op) => <ClientMethod operation={op} />);
   const name = `${schema.name}Client`;
 
   return <ts.ClassDeclaration name={name} export refkey={refkey(schema)}>
-    {methods}
+    <For each={schema.operations}>
+      {(op) => <ClientMethod operation={op} />}
+    </For>
   </ts.ClassDeclaration>;
 }
diff --git a/samples/client-emitter/src/components/Model.tsx b/samples/client-emitter/src/components/Model.tsx
index 4f6e1b5..cc08d86 100644
--- a/samples/client-emitter/src/components/Model.tsx
+++ b/samples/client-emitter/src/components/Model.tsx
@@ -1,4 +1,4 @@
-import { mapJoin, refkey } from "@alloy-js/core";
+import { For, refkey } from "@alloy-js/core";
 import * as ts from "@alloy-js/typescript";
 import { RestApiModel } from "../schema.js";
 import { ModelProperty } from "./ModelProperty.jsx";
@@ -6,10 +6,9 @@ interface ModelProps {
   model: RestApiModel;
 }
 export function Model(props: ModelProps) {
-  const members = mapJoin(props.model.properties, (
-    prop,
-  ) => <ModelProperty property={prop} />);
   return <ts.InterfaceDeclaration export name={props.model.name} refkey={refkey(props.model)}>
-    {members}
+    <For each={props.model.properties}>
+      {(prop) => <ModelProperty property={prop} />}
+    </For>
   </ts.InterfaceDeclaration>;
 }
diff --git a/samples/client-emitter/src/index.tsx b/samples/client-emitter/src/index.tsx
index b61cf09..a0b78b2 100644
--- a/samples/client-emitter/src/index.tsx
+++ b/samples/client-emitter/src/index.tsx
@@ -1,4 +1,4 @@
-import { mapJoin, Output, render, writeOutput } from "@alloy-js/core";
+import { For, Output, render, writeOutput } from "@alloy-js/core";
 import * as ts from "@alloy-js/typescript";
 
 import { Client } from "./components/Client.jsx";
@@ -6,8 +6,6 @@ import { Model } from "./components/Model.jsx";
 import { ApiContext, createApiContext } from "./context/api.js";
 import { api } from "./schema.js";
 
-const modelDecls = mapJoin(api.models, (model) => <Model model={model} />);
-
 const namePolicy = ts.createTSNamePolicy();
 
 const output = render(
@@ -15,7 +13,9 @@ const output = render(
     <ApiContext.Provider value={createApiContext(api)}>
       <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0">
         <ts.SourceFile path="models.ts">
-          {modelDecls}
+          <For each={api.models}>
+            {(model) => <Model model={model} />}
+          </For>
         </ts.SourceFile>
         <ts.SourceFile path="client.ts">
           <Client />


Test patch:
diff --git a/packages/core/test/control-flow/for.test.tsx b/packages/core/test/control-flow/for.test.tsx
new file mode 100644
index 0000000..e355c43
--- /dev/null
+++ b/packages/core/test/control-flow/for.test.tsx
@@ -0,0 +1,139 @@
+import "@alloy-js/core/testing";
+import { d, printTree } from "@alloy-js/core/testing";
+import { expect, it } from "vitest";
+import { For } from "../../src/components/For.jsx";
+import { onCleanup, reactive, renderTree } from "../../src/index.js";
+
+it("works", () => {
+  const messages = ["hi", "bye"];
+
+  const template =
+    <For each={messages}>
+      {(message) => <>
+        {message}, Jose!
+      </>}
+    </For>;
+
+  expect(template).toRenderTo(`
+    hi, Jose!
+    bye, Jose!
+  `);
+});
+
+it("doesn't rerender mappers", () => {
+  const messages = reactive(["hi", "bye"]);
+  let count = 0;
+  const template =
+    <For each={messages}>
+      {() => <>
+        item {count++}
+      </>}
+    </For>;
+  const tree = renderTree(template);
+  expect(count).toBe(2);
+
+  messages.push("maybe");
+
+  expect(count).toBe(3);
+  expect(printTree(tree)).toBe(d`
+    item 0
+    item 1
+    item 2
+  `);
+});
+
+it("doesn't rerender mappers (with splice)", () => {
+  const messages = reactive(["hi", "maybe", "bye"]);
+  let count = 0;
+  const template =
+    <For each={messages}>
+      {(msg) => <>
+        item {count++}
+      </>}
+    </For>;
+  const tree = renderTree(template);
+  expect(count).toBe(3);
+  messages.splice(1, 1);
+  // A sufficiently smart mapJoin would be able to handle this case...
+  // but for now we re-render everything after the splice point.
+  expect(count).toBe(4);
+  expect(printTree(tree)).toBe(d`
+    item 0
+    item 3
+  `);
+});
+
+it("cleans up things which end up removed (with push)", () => {
+  const cleanups: string[] = [];
+
+  function Letter(props: any) {
+    onCleanup(() => {
+      cleanups.push(props.letter);
+    });
+
+    return <>Letter {props.letter}</>;
+  }
+
+  const items = reactive(["a", "b"]);
+
+  const template =
+    <For each={items}>
+      {(item) => <Letter letter={item} />}
+    </For>;
+
+  const tree = renderTree(template);
+
+  expect(cleanups).toEqual([]);
+  expect(printTree(tree)).toBe(d`
+    Letter a
+    Letter b
+  `);
+
+  items.pop();
+  expect(cleanups).toEqual(["b"]);
+  expect(printTree(tree)).toBe(d`
+    Letter a
+  `);
+
+  items.pop();
+  expect(cleanups).toEqual(["b", "a"]);
+  expect(printTree(tree)).toBe("");
+});
+
+it("cleans up things which end up removed (with splice)", () => {
+  const cleanups: string[] = [];
+
+  function Letter(props: any) {
+    onCleanup(() => {
+      cleanups.push(props.letter);
+    });
+
+    return <>Letter {props.letter}</>;
+  }
+
+  const items = reactive(["a", "b", "c"]);
+
+  const template =
+    <For each={items}>
+      {(item) => <Letter letter={item} />}
+    </For>;
+
+  const tree = renderTree(template);
+
+  expect(cleanups).toEqual([]);
+  expect(printTree(tree)).toBe(d`
+    Letter a
+    Letter b
+    Letter c
+  `);
+
+  items.splice(1, 1);
+
+  // A sufficiently smart mapJoin would be able to handle this case...
+  // but for now we re-render everything after the splice point.
+  expect(cleanups).toEqual(["b", "c"]);
+  expect(printTree(tree)).toBe(d`
+    Letter a
+    Letter c
+  `);
+});
diff --git a/packages/core/test/control-flow/match.test.tsx b/packages/core/test/control-flow/match.test.tsx
new file mode 100644
index 0000000..fa72083
--- /dev/null
+++ b/packages/core/test/control-flow/match.test.tsx
@@ -0,0 +1,60 @@
+import "@alloy-js/core/testing";
+import { printTree } from "@alloy-js/core/testing";
+import { ref } from "@vue/reactivity";
+import { expect, it } from "vitest";
+import { Match, Switch } from "../../src/components/Switch.jsx";
+import { renderTree } from "../../src/render.js";
+
+it("selects the true branch", () => {
+  const template =
+    <Switch>
+      <Match when={true}>
+        true
+      </Match>
+      <Match else>
+        false
+      </Match>
+    </Switch>;
+  expect(template).toRenderTo(`true`);
+});
+it("selects the else branch", () => {
+  const template =
+    <Switch>
+      <Match when={false}>
+        true
+      </Match>
+      <Match else>
+        false
+      </Match>
+    </Switch>;
+  expect(template).toRenderTo(`false`);
+});
+
+it("renders to nothing when no branch matches", () => {
+  const template =
+    <Switch>
+      <Match when={false}>
+        true
+      </Match>
+    </Switch>;
+  expect(template).toRenderTo(``);
+});
+
+it("works with reactivity", () => {
+  let count = ref(0);
+  const template =
+    <Switch>
+      <Match when={count.value % 2 === 0}>
+        even
+      </Match>
+      <Match else>
+        odd
+      </Match>
+    </Switch>;
+  const tree = renderTree(template);
+  expect(printTree(tree)).toBe(`even`);
+  count.value++;
+  expect(printTree(tree)).toBe(`odd`);
+  count.value++;
+  expect(printTree(tree)).toBe(`even`);
+});
diff --git a/packages/core/test/reactivity/cleanup.test.tsx b/packages/core/test/reactivity/cleanup.test.tsx
new file mode 100644
index 0000000..0143086
--- /dev/null
+++ b/packages/core/test/reactivity/cleanup.test.tsx
@@ -0,0 +1,93 @@
+import { Children, effect, memo, onCleanup } from "@alloy-js/core/jsx-runtime";
+import { ref } from "@vue/reactivity";
+import { describe, expect, it } from "vitest";
+import { renderTree } from "../../src/render.js";
+
+describe("memo cleanup", () => {
+  it("cleans up when memo value is recomputed", () => {
+    const r = ref(1);
+    let callCount = 0;
+    const m = memo(() => {
+      onCleanup(() => {
+        callCount++;
+      });
+
+      return r.value;
+    });
+
+    expect(m()).toBe(1);
+    expect(callCount).toBe(0);
+
+    r.value = 2;
+
+    expect(m()).toBe(2);
+    expect(callCount).toBe(1);
+  });
+});
+
+describe("effect cleanup", () => {
+  it("cleans up when the effect is run", () => {
+    const r = ref(1);
+    let cleanedUp = false;
+    effect(() => {
+      onCleanup(() => {
+        cleanedUp = true;
+      });
+
+      return r.value;
+    });
+
+    expect(cleanedUp).toBe(false);
+
+    r.value = 2;
+
+    expect(cleanedUp).toBe(true);
+  });
+});
+
+describe("element cleanup", () => {
+  it("should clean up when the element is unmounted", () => {
+    let cleanedUp = false;
+    function Component() {
+      onCleanup(() => {
+        cleanedUp = true;
+      });
+      return "hi!";
+    }
+    const el = ref<unknown>(<Component />);
+    const template = <>
+      {el}
+    </>;
+    renderTree(template);
+    el.value = "";
+    expect(cleanedUp).toBe(true);
+  });
+
+  it("should clean up when the element is unmounted, recursively", () => {
+    let cleanedUpC1 = false;
+    let cleanedUpC2 = false;
+
+    function C1(props: { children: Children }) {
+      onCleanup(() => {
+        cleanedUpC1 = true;
+      });
+      return props.children;
+    }
+
+    function C2() {
+      onCleanup(() => {
+        cleanedUpC2 = true;
+      });
+    }
+    const el = ref<unknown>(<C1>
+      <C2></C2>
+    </C1>);
+    const template = <>
+      {el}
+    </>;
+    renderTree(template);
+    el.value = "";
+    expect(cleanedUpC1).toBe(true);
+    expect(cleanedUpC2).toBe(true);
+  });
+});
diff --git a/packages/core/test/reactivity/memo.test.tsx b/packages/core/test/reactivity/memo.test.tsx
new file mode 100644
index 0000000..311607f
--- /dev/null
+++ b/packages/core/test/reactivity/memo.test.tsx
@@ -0,0 +1,18 @@
+import { memo } from "@alloy-js/core/jsx-runtime";
+import { ref } from "@vue/reactivity";
+import { expect, it } from "vitest";
+
+it("doesn't recalculate when dependencies don't change", () => {
+  const signal = ref(0);
+  let callCount = 0;
+  const m = memo(() => {
+    callCount += 1;
+    return signal.value;
+  });
+  console.log("first call");
+  expect(callCount).toBe(1);
+  m();
+  expect(callCount).toBe(1);
+  m();
+  expect(callCount).toBe(1);
+});
diff --git a/packages/core/test/reactivity/test.test.tsx b/packages/core/test/reactivity/test.test.tsx
index e17b4fc..77760b7 100644
--- a/packages/core/test/reactivity/test.test.tsx
+++ b/packages/core/test/reactivity/test.test.tsx
@@ -38,10 +38,10 @@ it("works with a complex case", () => {
 
   function ImportStatements(props: ImportStatementsProps) {
     return memo(() =>
-      mapJoin(props.records, (
-        path,
-        types,
-      ) => <ImportStatement path={path} types={types} />));
+      mapJoin(
+        () => props.records,
+        (path, types) => <ImportStatement path={path} types={types} />,
+      ));
   }
 
   interface ImportStatementProps {
diff --git a/packages/core/test/reactivity/untrack.test.ts b/packages/core/test/reactivity/untrack.test.ts
new file mode 100644
index 0000000..2da2dd2
--- /dev/null
+++ b/packages/core/test/reactivity/untrack.test.ts
@@ -0,0 +1,33 @@
+import { memo, untrack } from "@alloy-js/core/jsx-runtime";
+import { ref } from "@vue/reactivity";
+import { expect, it } from "vitest";
+
+it("ignores signals for dependency tracking", () => {
+  const signal = ref(0);
+
+  const m = memo(() => {
+    return untrack(() => signal.value);
+  });
+
+  expect(m()).toBe(0);
+
+  signal.value = 1;
+
+  expect(m()).toBe(0);
+});
+
+it("doesn't affect signal changes", () => {
+  const signal = ref(0);
+
+  const m = memo(() => {
+    return signal.value;
+  });
+
+  expect(m()).toBe(0);
+
+  untrack(() => {
+    signal.value = 1;
+  });
+
+  expect(m()).toBe(1);
+});
diff --git a/packages/core/test/rendering/memoization.test.tsx b/packages/core/test/rendering/memoization.test.tsx
new file mode 100644
index 0000000..1952577
--- /dev/null
+++ b/packages/core/test/rendering/memoization.test.tsx
@@ -0,0 +1,32 @@
+import { memo } from "@alloy-js/core/jsx-runtime";
+import { ref } from "@vue/reactivity";
+import { expect, it } from "vitest";
+import { renderTree } from "../../src/render.js";
+
+it("memoizes child components", () => {
+  let renderCount = 0;
+  function Child() {
+    renderCount++;
+    return "hi " + String(Math.random());
+  }
+
+  const doThing = ref();
+  const child1 = <Child />;
+  const child2 = <Child />;
+  const items = memo(() => {
+    const list = [child1];
+    if (doThing.value) {
+      list.push(child2);
+    }
+
+    return list;
+  });
+
+  const template = <>
+    {items}
+  </>;
+
+  renderTree(template);
+  doThing.value = true;
+  expect(renderCount).toBe(2);
+});
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index b3147ec..21afdb0 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -562,3 +562,76 @@ describe("refkey resolution", () => {
     expect(resolvedSym.value?.targetDeclaration).toBe(sym);
   });
 });
+
+describe("Deleting symbols", () => {
+  it("updates resolutions", () => {
+    const key = refkey();
+    const binder = createOutputBinder();
+
+    const resolvedSym = binder.resolveDeclarationByKey(
+      undefined,
+      undefined,
+      key,
+    );
+
+    const sym = binder.createSymbol({
+      name: "foo",
+      scope: binder.globalScope,
+    });
+
+    expect(resolvedSym.value).toBe(undefined);
+
+    sym.refkey = key;
+    expect(resolvedSym.value?.targetDeclaration).toBe(sym);
+
+    binder.deleteSymbol(sym);
+
+    expect(resolvedSym.value).toBe(undefined);
+  });
+
+  it("removes from parent scopes", () => {
+    const binder = createOutputBinder();
+    const result = binder.resolveFQN("root.root#instance");
+    expect(result.value).toBeUndefined();
+
+    const {
+      symbols: { instance, root, staticc },
+      scopes: { rootScope },
+    } = createScopeTree(binder, {
+      rootScope: {
+        symbols: {
+          root: {
+            flags:
+              OutputSymbolFlags.InstanceMemberContainer |
+              OutputSymbolFlags.StaticMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.InstanceMember,
+              },
+            },
+            staticMembers: {
+              staticc: {
+                flags: OutputSymbolFlags.StaticMember,
+              },
+            },
+          },
+        },
+      },
+    });
+
+    const staticScope = root.staticMemberScope!;
+    const instanceScope = root.instanceMemberScope!;
+
+    expect(staticScope.symbols.size).toBe(1);
+    binder.deleteSymbol(staticc);
+    expect(staticScope.symbols.size).toBe(0);
+
+    expect(instanceScope.symbols.size).toBe(1);
+    binder.deleteSymbol(instance);
+    expect(instanceScope.symbols.size).toBe(0);
+
+    expect(rootScope.symbols.size).toBe(1);
+    binder.deleteSymbol(root);
+    expect(rootScope.symbols.size).toBe(0);
+  });
+});
diff --git a/packages/core/test/utils.test.tsx b/packages/core/test/utils.test.tsx
index a2e8a49..534a080 100644
--- a/packages/core/test/utils.test.tsx
+++ b/packages/core/test/utils.test.tsx
@@ -1,4 +1,6 @@
+import { computed, ref, triggerRef } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
+import { renderTree } from "../src/render.js";
 import { join, mapJoin } from "../src/utils.js";
 import "../testing/extend-expect.js";
 
@@ -13,12 +15,12 @@ describe("mapJoin", () => {
       return <>Key: {props.key}, Value: {props.value}</>;
     }
 
-    const joined = mapJoin(map, (
-      key,
-      value,
-    ) => <Foo key={key} value={value} />);
+    const joined = mapJoin(
+      () => map,
+      (key, value) => <Foo key={key} value={value} />,
+    );
 
-    expect(joined).toRenderTo(`
+    expect(joined()).toRenderTo(`
       Key: a, Value: 1
       Key: b, Value: 2
     `);
@@ -31,14 +33,65 @@ describe("mapJoin", () => {
       return <>Value: {props.value}</>;
     }
 
-    const joined = mapJoin(arr, (value) => <Foo value={value} />);
+    const joined = mapJoin(
+      () => arr,
+      (value) => <Foo value={value} />,
+    );
 
-    expect(joined).toRenderTo(`
+    expect(joined()).toRenderTo(`
       Value: 1
       Value: 2
     `);
   });
 
+  it("can map an array reactively (without render)", () => {
+    let callCount = 0;
+    const arr = ref([1, 2]);
+
+    function Foo(props: { value: number }) {
+      return <>Value: {props.value}</>;
+    }
+
+    const joined = mapJoin(
+      () => arr.value,
+      (value) => {
+        callCount++;
+        return <Foo value={value} />;
+      },
+    );
+
+    const len = computed(() => {
+      return joined().length;
+    });
+    expect(len.value).toBe(4);
+    arr.value.push(3);
+    triggerRef(arr);
+    expect(len.value).toBe(6);
+  });
+
+  it("can map an array reactively (with render)", () => {
+    let callCount = 0;
+    const arr = ref([1, 2]);
+
+    function Foo(props: { value: number }) {
+      callCount++;
+      return <>Value: {props.value}</>;
+    }
+
+    const joined = mapJoin(
+      () => arr.value,
+      (value) => {
+        return <Foo value={value} />;
+      },
+    );
+
+    renderTree(joined);
+
+    expect(callCount).toBe(2);
+    arr.value.push(3);
+    triggerRef(arr);
+    expect(callCount).toBe(3);
+  });
   it("can map a joiner", () => {
     const arr = [1, 2];
 
@@ -46,9 +99,13 @@ describe("mapJoin", () => {
       return <>Value: {props.value}</>;
     }
 
-    const joined = mapJoin(arr, (value) => <Foo value={value} />, {
-      joiner: "-",
-    });
+    const joined = mapJoin(
+      () => arr,
+      (value) => <Foo value={value} />,
+      {
+        joiner: "-",
+      },
+    );
 
     expect(joined).toRenderTo(`
       Value: 1-Value: 2
@@ -62,10 +119,14 @@ describe("mapJoin", () => {
       return <>Value: {props.value}</>;
     }
 
-    const joined = mapJoin(arr, (value) => <Foo value={value} />, {
-      joiner: "-",
-      ender: ";",
-    });
+    const joined = mapJoin(
+      () => arr,
+      (value) => <Foo value={value} />,
+      {
+        joiner: "-",
+        ender: ";",
+      },
+    );
 
     expect(joined).toRenderTo(`
       Value: 1-Value: 2;
@@ -79,10 +140,14 @@ describe("mapJoin", () => {
       return <>Value: {props.value}</>;
     }
 
-    const joined = mapJoin(arr, (value) => <Foo value={value} />, {
-      joiner: "-",
-      ender: ";",
-    });
+    const joined = mapJoin(
+      () => arr,
+      (value) => <Foo value={value} />,
+      {
+        joiner: "-",
+        ender: ";",
+      },
+    );
 
     expect(joined).toRenderTo(`
       Value: 1-Value: 2;
diff --git a/packages/json/test/array.test.tsx b/packages/json/test/array.test.tsx
index 9473a01..deaeb23 100644
--- a/packages/json/test/array.test.tsx
+++ b/packages/json/test/array.test.tsx
@@ -1,4 +1,6 @@
+import { reactive, renderTree } from "@alloy-js/core";
 import "@alloy-js/core/testing";
+import { d, printTree } from "@alloy-js/core/testing";
 import { expect, it } from "vitest";
 import { jsonTest } from "./utils.jsx";
 
@@ -29,3 +31,23 @@ it("renders nested arrays", () => {
     ]
   `);
 });
+
+it("works reactively", () => {
+  const arr = reactive([] as number[]);
+
+  const template = jsonTest(arr);
+  const tree = renderTree(template);
+
+  expect(printTree(tree)).toEqual(`[]`);
+
+  arr.push(1);
+  expect(printTree(tree)).toEqual(`[1]`);
+
+  arr.push(2);
+  expect(printTree(tree)).toEqual(d`
+    [
+      1,
+      2
+    ]
+  `);
+});
diff --git a/packages/json/test/object.test.tsx b/packages/json/test/object.test.tsx
index b645e23..3d6a0e7 100644
--- a/packages/json/test/object.test.tsx
+++ b/packages/json/test/object.test.tsx
@@ -1,5 +1,6 @@
-import { Output } from "@alloy-js/core";
+import { Output, reactive, renderTree } from "@alloy-js/core";
 import "@alloy-js/core/testing";
+import { d, printTree } from "@alloy-js/core/testing";
 import { expect, it } from "vitest";
 import {
   JsonObject,
@@ -58,3 +59,33 @@ it("can can manually assemble objects", () => {
     }
   `);
 });
+
+it("works reactively", () => {
+  const obj = reactive({ a: 1 } as Record<string, unknown>);
+
+  const template = jsonTest(obj);
+  const tree = renderTree(template);
+
+  expect(printTree(tree)).toEqual(d`
+    {
+      "a": 1
+    }
+  `);
+
+  (obj.a as any)++;
+
+  expect(printTree(tree)).toEqual(d`
+    {
+      "a": 2
+    }
+  `);
+
+  obj.b = "hello";
+
+  expect(printTree(tree)).toEqual(d`
+    {
+      "a": 2,
+      "b": "hello"
+    }
+  `);
+});
diff --git a/packages/typescript/test/values.test.tsx b/packages/typescript/test/values.test.tsx
index 64bdf11..95e3ff4 100644
--- a/packages/typescript/test/values.test.tsx
+++ b/packages/typescript/test/values.test.tsx
@@ -37,7 +37,7 @@ it("renders an object with properties, mapped", () => {
     ["bar", "2"],
   ]);
   const props = mapJoin(
-    propValues,
+    () => propValues,
     (name, value) => {
       return <ts.ObjectProperty name={name} value={value} />;
     },