Coming from Vue
A guide for Vue developers learning Effex. This covers the key differences, concept mapping, and side-by-side examples to help you transition.
Why Switch?
If you’re already using Effect in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between Vue’s reactivity model and Effect’s compositional approach.
Typed Error Handling
In Vue, component errors are runtime surprises. You catch them with errorCaptured hooks or global error handlers, but there’s no compile-time visibility into what can fail.
In Effex, every element has type Element<E, R> where E is the error channel. Errors propagate through the component tree, and you must handle them before mounting:
// This won't compile — UserProfile might fail with ApiError
mount(UserProfile(), document.body); // Type error!
// Handle the error first
mount(
Boundary.error(
() => UserProfile(),
(error) => $.div({}, $.of(`Failed to load: ${error.message}`)),
),
document.body,
); // Compiles
TypeScript tells you at build time which components can fail and forces you to handle it.
Similar Reactivity, Different Execution
Vue’s Composition API and Effex share similar reactive concepts — both have signals (refs) and derived values (computed). The key difference is when things run:
- Vue: Template re-renders when refs change, computed values update lazily
- Effex: DOM nodes subscribe directly to signals, updates are synchronous and targeted
// Vue: Computed re-evaluates, template re-renders
const count = ref(0);
const doubled = computed(() => count.value * 2);
// Template: {{ doubled }} — entire template function runs
// Effex: Only the text node updates
const count = yield* Signal.make(0);
const doubled = Readable.map(count, (c) => c * 2);
// $.span({}, $.of(doubled)) — only this span's text updates
No Template Compilation
Vue uses a custom template syntax that compiles to render functions. Effex uses plain TypeScript function calls:
// Vue template
<template>
<div class="card">
<h1>{{ title }}</h1>
<button @click="handleClick">Submit</button>
</div>
</template>
// Effex
$.div(
{ class: "card" },
collect(
$.h1({}, $.of(title)),
$.button({ onClick: handleClick }, $.of("Submit")),
),
)
Benefits:
- Full TypeScript inference everywhere
- No build step required for templates
- Easier to debug (no compiled output to trace through)
- IDE features work perfectly (rename, find references, etc.)
Automatic Resource Cleanup
Vue’s onUnmounted and watchEffect cleanup are manual. Effex uses Effect’s scope system — resources are automatically cleaned up when components unmount:
// Vue: Manual cleanup registration
onMounted(() => {
const subscription = eventSource.subscribe(handler);
onUnmounted(() => subscription.unsubscribe());
});
// Effex: Automatic cleanup via scope
yield* eventSource.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope), // Cleaned up when scope closes
);
Better Async Integration
Vue’s <Suspense> is limited and doesn’t integrate well with error handling. Effex has two approaches:
// Option 1: Boundary.suspense (one-shot)
Boundary.suspense({
render: () =>
Effect.gen(function* () {
const user = yield* fetchUser(id); // Can fail!
return yield* UserProfile({ user });
}),
fallback: () => $.div({}, $.of("Loading...")),
catch: (error) => $.div({}, $.of(`Error: ${error.message}`)),
delay: "200 millis", // Avoid loading flash
});
// Option 2: AsyncReadable (reactive, with refetch)
const userData = yield* AsyncReadable.make(() => fetchUser(id));
// AsyncReadable has separate Readables for fine-grained reactivity
$.div(
{},
collect(
when(userData.isLoading, {
onTrue: () => $.div({}, $.of("Loading...")),
onFalse: () => $.span(),
}),
matchOption(userData.value, {
onSome: (user) => UserProfile({ user }),
onNone: () => $.span(),
}),
matchOption(userData.error, {
onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))),
onNone: () => $.span(),
}),
),
);
Concept Mapping
| Vue (Composition API) | Effex | Notes |
|---|---|---|
ref(initial) |
Signal.make(initial) |
Must yield* to create |
reactive(obj) |
Signal.make(obj) |
Same as ref for objects |
computed(() => x) |
Readable.map(dep, fn) |
Derives from a readable |
watch(source, cb) |
Readable.tap(source, fn) |
Automatic cleanup |
watchEffect(cb) |
Readable.tap(source, fn) |
Explicit source |
provide/inject |
yield* ServiceTag |
Effect services |
ref (template ref) |
ref<T>() |
For DOM element refs |
v-if / v-else |
when(cond, { onTrue, onFalse }) |
Object config |
v-if="x != null" |
matchOption(optX, { onSome, onNone }) |
Unwraps Option |
v-show |
Signal-based class/style | No direct equivalent |
v-for |
each(arr, { key, render }) |
Key function, not :key |
@click / v-on |
onClick / event props |
Camel case handlers |
:class / v-bind:class |
class prop with Readable |
Reactive by default |
<Teleport> |
Portal() |
Similar API |
<Suspense> |
Boundary.suspense or AsyncReadable |
Multiple options |
defineProps |
Function parameters | Plain TypeScript |
defineEmits |
Callback props | Plain functions |
SFC .vue files |
Plain .ts files |
No special file format |
Side-by-Side Examples
State and Updates
<!-- Vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
// Effex
const Counter = () =>
Effect.gen(function* () {
const count = yield* Signal.make(0);
return yield* $.button(
{ onClick: () => count.update((c) => c + 1) },
$.of(count),
);
});
Computed / Derived State
<!-- Vue -->
<script setup>
import { ref, computed } from 'vue'
const items = ref([])
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price, 0)
)
</script>
<template>
<div>Total: ${{ total }}</div>
</template>
// Effex
const Cart = (props: { items: Readable.Readable<Item[]> }) =>
Effect.gen(function* () {
const total = Readable.map(props.items, (items) =>
items.reduce((sum, i) => sum + i.price, 0),
);
return yield* $.div({}, t`Total: $${total}`);
});
Conditional Rendering
<!-- Vue -->
<script setup>
import { ref } from 'vue'
const isLoggedIn = ref(false)
</script>
<template>
<Dashboard v-if="isLoggedIn" />
<Login v-else />
</template>
// Effex
const Auth = (props: { isLoggedIn: Readable.Readable<boolean> }) =>
when(props.isLoggedIn, {
onTrue: () => Dashboard(),
onFalse: () => Login(),
});
Lists
<!-- Vue -->
<script setup>
import { ref } from 'vue'
const todos = ref([])
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
</li>
</ul>
</template>
// Effex
const TodoList = (props: { todos: Readable.Readable<Todo[]> }) =>
each(props.todos, {
container: () => $.ul(),
key: (todo) => todo.id,
render: (todo) =>
$.li({}, $.of(Readable.map(todo, (t) => t.text))),
});
Watchers / Reactions
<!-- Vue -->
<script setup>
import { ref, watch } from 'vue'
const title = ref('My App')
const unreadCount = ref(0)
watch([title, unreadCount], ([newTitle, count]) => {
document.title = count > 0 ? `(${count}) ${newTitle}` : newTitle
})
watch(title, (newTitle) => {
localStorage.setItem('lastTitle', newTitle)
})
</script>
<template>
<h1>{{ title }}</h1>
</template>
// Effex
const DocumentTitle = (props: {
title: Readable.Readable<string>;
unreadCount: Readable.Readable<number>;
}) =>
Effect.gen(function* () {
const combined = Readable.zipWith(props.title, props.unreadCount, (title, count) =>
count > 0 ? `(${count}) ${title}` : title,
);
yield* Readable.tap(combined, (t) =>
Effect.sync(() => { document.title = t; }),
);
yield* Readable.tap(props.title, (title) =>
Effect.sync(() => localStorage.setItem("lastTitle", title)),
);
return yield* $.h1({}, $.of(props.title));
});
Provide / Inject (Services)
<!-- Vue Parent -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
<!-- Vue Child -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>
<template>
<div :class="theme">...</div>
</template>
// Effex
class ThemeService extends Context.Tag("Theme")<ThemeService, string>() {}
const Page = () =>
Effect.gen(function* () {
const theme = yield* ThemeService;
return yield* $.div({ class: theme }, $.of("..."));
});
// Provide at mount
runApp(mount(Page().pipe(Effect.provideService(ThemeService, "dark")), root));
// Or provide inline
$.div(
{ class: "app" },
provide(ThemeService, "dark", Page()),
);
Two-Way Binding (v-model)
<!-- Vue -->
<script setup>
import { ref } from 'vue'
const text = ref('')
</script>
<template>
<input v-model="text" />
<p>You typed: {{ text }}</p>
</template>
// Effex
const TextInput = () =>
Effect.gen(function* () {
const text = yield* Signal.make("");
return yield* $.div(
{},
collect(
$.input({
value: text,
onInput: (e) => text.set((e.target as HTMLInputElement).value),
}),
$.p({}, t`You typed: ${text}`),
),
);
});
Teleport / Portal
<!-- Vue -->
<template>
<Teleport to="body">
<div class="modal">Modal content</div>
</Teleport>
</template>
// Effex
const Modal = () =>
Portal(() =>
$.div({ class: "modal" }, $.of("Modal content")),
);
// Or with a specific target
Portal({ target: "#modal-root" }, () =>
$.div({ class: "modal" }, $.of("Modal content")),
);
Key Mindset Shifts
-
No template syntax — Everything is TypeScript.
v-ifbecomeswhen(),v-forbecomeseach(),@clickbecomesonClick. -
Explicit sources — Vue’s
watchEffectauto-tracks. Effex’sReadable.taprequires an explicit readable to subscribe to. -
Errors are values — Instead of
errorCapturedhooks, errors flow through the type system. Handle them explicitly withBoundary.error. -
Effects are explicit — Side effects aren’t hidden in
watchEffect. They’reReadable.tapsubscriptions that you set up explicitly. -
No SFC magic — No
<script setup>, nodefineProps, no compiler macros. Just TypeScript functions. -
Cleanup is automatic — Effect’s scope system handles resource cleanup. No more forgotten cleanup in
onUnmounted.
Custom Equality
In Vue, watch and computed use reference equality by default. You can pass { deep: true } for deep comparison, but there’s no custom equality.
In Effex, equality is a first-class option on every reactive primitive:
// Only trigger updates when the user ID changes, ignoring lastSeen timestamps
const currentUser = yield* Signal.make<User>(
{ id: 1, name: "Alice", lastSeen: new Date() },
{ equals: (a, b) => a.id === b.id },
);
Imperative DOM Access
In Vue, you use template refs to get DOM element references:
<!-- Vue -->
<script setup>
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
const handleFocus = () => {
inputRef.value?.focus()
inputRef.value?.scrollIntoView({ behavior: 'smooth' })
inputRef.value?.classList.add('focused')
}
</script>
<template>
<input ref="inputRef" @click="handleFocus" />
</template>
In Effex, ref() creates a pipeable element reference:
// Effex
const FocusInput = () =>
Effect.gen(function* () {
const inputRef = yield* ref<HTMLInputElement>();
const handleFocus = () =>
inputRef.pipe(
Element.focus,
Element.scrollIntoView({ behavior: "smooth" }),
Element.addClass("focused"),
);
return yield* $.input({ ref: inputRef, onClick: handleFocus });
});
Common Vue DOM Patterns
| Vue Pattern | Effex Equivalent |
|---|---|
ref.value?.focus() |
el.pipe(Element.focus) |
ref.value?.blur() |
el.pipe(Element.blur) |
ref.value?.click() |
el.pipe(Element.click) |
ref.value?.scrollIntoView() |
el.pipe(Element.scrollIntoView()) |
ref.value?.classList.add("x") |
el.pipe(Element.addClass("x")) |
ref.value?.classList.remove("x") |
el.pipe(Element.removeClass("x")) |
ref.value?.classList.toggle("x") |
el.pipe(Element.toggleClass("x")) |
ref.value?.setAttribute("k", "v") |
el.pipe(Element.setAttribute("k", "v")) |
ref.value?.dataset.state = "x" |
el.pipe(Element.setData("state", "x")) |
ref.value?.style.color = "red" |
el.pipe(Element.setStyle("color", "red")) |
ref.value?.querySelector(".x") |
el.pipe(Element.querySelector(".x")) |
Animation Hooks
Effex’s animation system passes elements to lifecycle hooks, letting you use Element helpers:
when(isModalOpen, {
onTrue: () => Modal(),
onFalse: () => $.span(),
animate: {
enter: "fade-in",
exit: "fade-out",
onEnter: (el) => el.pipe(Element.focusFirst("[data-autofocus]")),
onBeforeExit: (el) => el.pipe(Element.blur),
},
});
This is similar to Vue’s <Transition> hooks but with pipeable operations instead of imperative code.