# Coming from React A guide for React 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](https://effect.website/) in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between React's hooks model and Effect's compositional approach. ### Typed Error Handling In React, component errors are runtime surprises. You catch them with error boundaries, but there's no compile-time visibility into what can fail. In Effex, every element has type `Element` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting: ```ts // 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. ### Fine-Grained Reactivity (No Virtual DOM) React re-renders entire component subtrees when state changes, then diffs a virtual DOM to find what actually changed. This works, but it's wasteful. Effex uses signals. When a signal updates, only the DOM nodes that actually depend on that signal update. No diffing, no wasted renders: ```ts // React: Changing count re-renders the entire component function Counter() { const [count, setCount] = useState(0); console.log("render"); // Logs on every click return
{count}
; } // Effex: Only the text node updates const Counter = () => Effect.gen(function* () { const count = yield* Signal.make(0); console.log("render"); // Logs once, on mount return yield* $.div({}, $.of(count)); // count changes update only this text }); ``` ### No Rules of Hooks React hooks have rules you must memorize: - Don't call hooks conditionally - Exhaustive dependency arrays (with lint rules that don't always help) - Stale closure bugs when you forget a dependency - `useCallback` and `useMemo` everywhere for performance Effex has none of this. Create signals wherever you want. Use them wherever you want. The reactivity system tracks dependencies automatically: ```ts // React: Must memoize, manage deps, avoid stale closures const [items, setItems] = useState([]); const handleAdd = useCallback(() => { setItems((prev) => [...prev, newItem]); // Must use prev, not items! }, []); // Stale closure if you use items directly // Effex: Just write code const items = yield* Signal.make([]); const handleAdd = () => items.update((current) => [...current, newItem]); // Always fresh ``` ### Automatic Resource Cleanup React's `useEffect` cleanup is manual and easy to get wrong. Forget to clean up a subscription? Memory leak. Return a non-function? Runtime error. Effex uses Effect's scope system. Resources are automatically cleaned up when components unmount: ```ts // React: Manual cleanup, easy to forget useEffect(() => { const subscription = eventSource.subscribe(handler); return () => subscription.unsubscribe(); // Don't forget! }, []); // Effex: Automatic cleanup via scope yield* eventSource.pipe( Stream.runForEach(handler), Effect.forkIn(scope), // Cleaned up when scope closes ); ``` ### No Re-render Cascades In React, when a parent re-renders, all children re-render too (unless wrapped in `React.memo`). This leads to prop drilling `memo` everywhere or using context for everything. In Effex, signal updates only notify actual subscribers. Parent "re-renders" don't exist: ```ts // React: Parent re-render causes child re-render function Parent() { const [count, setCount] = useState(0); // Child re-renders too! return ; // Unless wrapped in memo() } // Effex: Parent signal doesn't affect unrelated children const Parent = () => Effect.gen(function* () { const count = yield* Signal.make(0); // Child doesn't care return yield* $.div({}, Child()); // Child never "re-renders" }); ``` ### Better Async React's Suspense requires experimental features for data fetching, and error handling is separate from loading states. In Effex, it's unified: ```ts 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 }); ``` ## Concept Mapping | React | Effex | Notes | |---|---|---| | `useState(initial)` | `Signal.make(initial)` | Must `yield*` to create | | `useMemo(() => x, deps)` | `Readable.map(dep, (v) => x)` | Auto-tracked, no dep arrays | | `useEffect(() => {...}, deps)` | `Readable.tap(dep, fn)` | Automatic cleanup | | `useCallback(fn, deps)` | Just use the function | No stale closures | | `useContext(Ctx)` | `yield* ServiceTag` | Effect services | | `useRef(initial)` | `ref()` | For DOM element refs | | `` | `Component({ prop: x })` | Function calls | | `{cond && }` | `when(cond, { onTrue: () => El(), onFalse: () => $.span() })` | Object config | | `{x != null && }` | `matchOption(optX, { onSome: (x) => El({ x }), onNone: ... })` | Unwraps Option | | `{arr.map(x => )}` | `each(arr, { key: x => x.id, render: x => El() })` | Key function, not prop | | `` | `Boundary.error(try, catch)` | Typed errors! | | `` | `Boundary.suspense({ render, fallback })` | With typed `catch` | | Component re-render | Doesn't exist | Only signals update DOM | | Virtual DOM diff | Doesn't exist | Direct DOM updates | | `React.memo()` | Not needed | Fine-grained by default | ## Side-by-Side Examples ### State and Updates ```tsx // React function Counter() { const [count, setCount] = useState(0); return ; } // Effex const Counter = () => Effect.gen(function* () { const count = yield* Signal.make(0); return yield* $.button( { onClick: () => count.update((c) => c + 1) }, $.of(count), ); }); ``` ### Derived State ```tsx // React function Cart({ items }) { const total = useMemo( () => items.reduce((sum, i) => sum + i.price, 0), [items], ); return
Total: ${total}
; } // Effex const Cart = (props: { items: Readable.Readable }) => 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 ```tsx // React function Auth({ isLoggedIn }) { return isLoggedIn ? : ; } // Effex const Auth = (props: { isLoggedIn: Readable.Readable }) => when(props.isLoggedIn, { onTrue: () => Dashboard(), onFalse: () => Login(), }); ``` ### Lists ```tsx // React function TodoList({ todos }) { return (
    {todos.map((todo) => (
  • {todo.text}
  • ))}
); } // Effex const TodoList = (props: { todos: Readable.Readable }) => each(props.todos, { container: () => $.ul(), key: (todo) => todo.id, render: (todo) => $.li({}, $.of(Readable.map(todo, (t) => t.text))), }); ``` ### Data Fetching ```tsx // React (with Suspense + error boundary) function UserProfile({ id }) { const user = use(fetchUser(id)); // Experimental return
{user.name}
; } // Wrapped in error boundary + suspense elsewhere... // Effex — Option 1: Boundary.suspense (one-shot) const UserProfile = (props: { id: string }) => Boundary.suspense({ render: () => Effect.gen(function* () { const user = yield* fetchUser(props.id); return yield* $.div({}, $.of(user.name)); }), fallback: () => $.div({}, $.of("Loading...")), catch: (e) => $.div({}, $.of(`Error: ${e}`)), }); // Effex — Option 2: AsyncReadable (reactive, with refetch) const UserProfileAsync = (props: { id: string }) => Effect.gen(function* () { const userData = yield* AsyncReadable.make(() => fetchUser(props.id)); return yield* $.div( {}, collect( when(userData.isLoading, { onTrue: () => $.div({}, $.of("Loading...")), onFalse: () => $.span(), }), matchOption(userData.value, { onSome: (user) => $.div({}, $.of(Readable.map(user, (u) => u.name))), onNone: () => $.span(), }), matchOption(userData.error, { onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))), onNone: () => $.span(), }), ), ); }); ``` ### Context / Services ```tsx // React const ThemeContext = createContext("light"); function App() { return ( ); } function Page() { const theme = useContext(ThemeContext); return
...
; } // Effex class ThemeService extends Context.Tag("Theme")() {} 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()), ); ``` ### Effects / Reactions ```tsx // React function DocumentTitle({ title, unreadCount }) { useEffect(() => { document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title; }, [title, unreadCount]); useEffect(() => { localStorage.setItem("lastTitle", title); }, [title]); return

{title}

; } // Effex const DocumentTitle = (props: { title: Readable.Readable; unreadCount: Readable.Readable; }) => Effect.gen(function* () { // Runs whenever title or unreadCount changes 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; }), ); // Runs whenever title changes yield* Readable.tap(props.title, (title) => Effect.sync(() => localStorage.setItem("lastTitle", title)), ); return yield* $.h1({}, $.of(props.title)); }); ``` Key differences: - **No dependency arrays to maintain** — `Readable.tap` subscribes to the readable it's given - **No stale closure bugs** — Values are passed as parameters, not captured from scope - **Automatic cleanup** — Subscriptions stop when the component unmounts ## Key Mindset Shifts 1. **Components don't re-render** — There's no render cycle. Signals update, and only their subscribers react. 2. **Errors are values** — Instead of try/catch around everything, errors flow through the type system. Handle them explicitly with `Boundary.error` or Effect combinators. 3. **Effects are explicit** — Side effects aren't hidden in `useEffect`. They're `Readable.tap` subscriptions or Effect values that you compose and run. 4. **Cleanup is automatic** — Effect's scope system handles resource cleanup. No more forgotten unsubscribes. ## Custom Equality In React, `useMemo` and `useEffect` use dependency arrays with shallow comparison, and there's no built-in way to customize equality. In Effex, equality is a first-class option on every reactive primitive: ```ts // Only trigger updates when the user ID changes, ignoring lastSeen timestamps const currentUser = yield* Signal.make( { id: 1, name: "Alice", lastSeen: new Date() }, { equals: (a, b) => a.id === b.id }, ); ``` ## Imperative DOM Access In React, you use `useRef` to get DOM element references: ```tsx // React function FocusInput() { const inputRef = useRef(null); const handleFocus = () => { inputRef.current?.focus(); inputRef.current?.scrollIntoView({ behavior: "smooth" }); inputRef.current?.classList.add("focused"); }; return ; } ``` In Effex, `ref()` creates a pipeable element reference: ```ts // Effex const FocusInput = () => Effect.gen(function* () { const inputRef = yield* ref(); const handleFocus = () => inputRef.pipe( Element.focus, Element.scrollIntoView({ behavior: "smooth" }), Element.addClass("focused"), ); return yield* $.input({ ref: inputRef, onClick: handleFocus }); }); ``` ### Common React DOM Patterns | React Pattern | Effex Equivalent | |---|---| | `ref.current?.focus()` | `el.pipe(Element.focus)` | | `ref.current?.blur()` | `el.pipe(Element.blur)` | | `ref.current?.click()` | `el.pipe(Element.click)` | | `ref.current?.scrollIntoView()` | `el.pipe(Element.scrollIntoView())` | | `ref.current?.classList.add("x")` | `el.pipe(Element.addClass("x"))` | | `ref.current?.classList.remove("x")` | `el.pipe(Element.removeClass("x"))` | | `ref.current?.classList.toggle("x")` | `el.pipe(Element.toggleClass("x"))` | | `ref.current?.setAttribute("k", "v")` | `el.pipe(Element.setAttribute("k", "v"))` | | `ref.current?.removeAttribute("k")` | `el.pipe(Element.removeAttribute("k"))` | | `ref.current?.dataset.state = "x"` | `el.pipe(Element.setData("state", "x"))` | | `ref.current?.style.color = "red"` | `el.pipe(Element.setStyle("color", "red"))` | | `ref.current?.querySelector(".x")` | `el.pipe(Element.querySelector(".x"))` | ### Animation Hooks Effex's animation system passes elements to lifecycle hooks, letting you use Element helpers: ```ts 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), }, }); ```