Building ftextarea: A Vanilla Rust + WebAssembly Textarea App

I wanted a simple textarea where I could paste links and jot down quick notes. No login, no cloud sync, no fancy formatting — just a textarea that saves to localStorage. Most existing tools are overkill for this use case.

So I built ftextarea — and used it as an excuse to learn Rust + WebAssembly without frameworks.

Try the live app →

View the source code →

The Goal

Build a static web page with:

  • A full-screen textarea
  • Auto-save to localStorage (with debounce)
  • Multi-tab synchronization
  • Offline support via Service Worker
  • Core logic in Rust, compiled to WebAssembly

No Yew, no Dioxus, no JavaScript frameworks — just vanilla HTML, CSS, and Rust/WASM. (Modal handling stays in plain JS since it’s pure UI chrome.)

Project Setup

First, create a new Rust library project:

cargo init --lib ftextarea
cd ftextarea

Cargo.toml

The key dependencies for a vanilla Rust/WASM project:

[package]
name = "ftextarea"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2.100"
console_error_panic_hook = "0.1"

[dependencies.web-sys]
version = "0.3.77"
features = [
    "Window",
    "Document",
    "EventTarget",
    "HtmlTextAreaElement",
    "Storage",
    "StorageEvent",
    "Event",
    "VisibilityState",
    "console",
]

Let me explain what each part does:

  • crate-type = ["cdylib", "rlib"]cdylib produces a dynamic library that can be compiled to WASM. rlib allows running tests with cargo test.

  • wasm-bindgen — The bridge between Rust and JavaScript. It handles converting types between the two languages and generates the JS glue code.

  • console_error_panic_hook — When Rust panics, this logs the error message to the browser console. Without it, you’d just see cryptic WASM errors.

  • web-sys — Rust bindings to Web APIs. You enable only the features you need (each feature is a web API like Document, Window, etc.). This keeps the bundle small.

Error Handling Philosophy

Before diving into the code, let’s talk about error handling. This crate uses panic! liberally:

// This crate uses panic! liberally for extremely unlikely and/or unrecoverable scenarios:
// - Missing window/document/localStorage: app can't function without these
// - Missing DOM elements: programmer error (HTML mismatch), not a runtime condition
// Panicking keeps the code simple and gives clear error messages. Graceful error
// handling would add complexity for scenarios that basically never happen.

Why panic instead of returning Option or Result?

  • Missing window or document: These are always present in a browser. If they’re missing, you’re in a Web Worker or non-browser environment — the app is useless anyway.
  • Missing localStorage: Even in private browsing mode, localStorage works (data just gets cleared on session end). It basically never fails.
  • Missing DOM elements: If the element ID doesn’t exist, your HTML is broken. That’s a programmer error, not a runtime condition.

For a simple app like this, panicking gives clear error messages and keeps the code simple. We’re not building a library that needs to handle every edge case.

The Rust Code

DOM Helpers

Getting elements by ID with type casting:

use wasm_bindgen::JsCast;

fn get_element<T: JsCast>(id: &str) -> T {
    // Panics if element doesn't exist or is the wrong type.
    // Missing elements are programmer errors (HTML mismatch), not runtime conditions,
    // so we panic immediately rather than returning Option.
    document()
        .get_element_by_id(id)
        .and_then(|el| el.dyn_into::<T>().ok())
        .unwrap_or_else(|| panic!("element '{}' not found or wrong type", id))
}

JsCast is a trait from wasm_bindgen that allows casting JavaScript values to specific Rust types. The dyn_into::<T>() method attempts the cast and returns Result.

Note that we panic instead of returning Option — callers don’t need to handle the missing element case because it should never happen in a correctly-deployed app.

localStorage Operations

const STORAGE_KEY: &str = "ftextarea_content";

pub struct LocalStorage {
    storage: web_sys::Storage,
}

impl LocalStorage {
    pub fn new() -> Self {
        Self { storage: local_storage() }
    }
}

fn local_storage() -> web_sys::Storage {
    // Panics if unavailable, but this basically never happens in practice
    // (even works in private mode), so we're fine without a fallback.
    window()
        .local_storage()
        .ok()
        .flatten()
        .expect("localStorage not available")
}

The .ok().flatten() chain handles the nested Result<Option<Storage>, JsValue> return type:

  1. .ok() converts Result to Option, discarding any error
  2. .flatten() converts Option<Option<T>> to Option<T>
  3. .expect() panics with a message if None

Loading and saving:

impl TextStorage for LocalStorage {
    fn load(&self) -> String {
        // Returns empty string if nothing stored yet (first visit) or on error.
        // Both cases are fine — just start with a blank textarea.
        self.storage
            .get_item(STORAGE_KEY)
            .ok()
            .flatten()
            .unwrap_or_default()
    }

    fn save(&self, content: &str) {
        // Panics if storage quota is exceeded (~5-10MB) or storage is disabled.
        // For a plain text editor, hitting the quota is unlikely.
        self.storage
            .set_item(STORAGE_KEY, content)
            .expect("failed to save to localStorage");
    }
}

For load(), we return empty string on failure — that’s fine, just start with a blank textarea. For save(), we panic because failing to save means data loss, and the user should know.

Entry Point

The #[wasm_bindgen(start)] attribute marks a function to run automatically when the WASM module loads:

use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn init() {
    // Set up panic hook for better error messages in the console
    console_error_panic_hook::set_once();

    let storage = Rc::new(LocalStorage::new());
    let editor: HtmlTextAreaElement = get_element("editor");

    // Load saved content
    editor.set_value(&storage.load());

    // Set up event listeners...
}

Event Listeners

Adding event listeners requires understanding closures in Rust:

fn add_event_listener<T, F>(target: &T, event_type: &str, handler: F)
where
    T: AsRef<web_sys::EventTarget>,
    F: FnMut(web_sys::Event) + 'static,
{
    let closure = Closure::wrap(Box::new(handler) as Box<dyn FnMut(_)>);
    target
        .as_ref()
        .add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())
        .expect("failed to add event listener");
    closure.forget();
}

A few Rust concepts here:

  • Closure — A wasm_bindgen type that wraps a Rust closure so it can be called from JavaScript. Without this wrapper, Rust closures can’t cross the WASM boundary.

  • Box<dyn FnMut(_)> — A heap-allocated trait object. dyn means “dynamic dispatch” — the concrete type is determined at runtime. We need this because the closure type is anonymous.

  • closure.forget() — Prevents Rust from dropping (freeing) the closure. Normally Rust would clean up the memory when the closure goes out of scope, but we need it to stay alive for JavaScript callbacks. This does leak memory, but for a small number of event handlers it’s acceptable.

  • 'static — A lifetime bound meaning the closure can live for the entire program duration. Required because we don’t know when JavaScript will call it.

The Clone Pattern for Closures

When you need to use variables inside closures that will be stored and called later, you need to clone them. Here’s the idiomatic pattern using variable shadowing:

add_event_listener(&editor, "input", {
    let editor = editor.clone();
    let storage = storage.clone();
    move |_| {
        // use editor, storage here
    }
});

The move keyword forces the closure to take ownership of the captured variables. Without it, the closure would try to borrow variables that get dropped when the block ends.

For nested closures (like with debouncing), you need to clone again inside:

add_event_listener(&editor, "input", {
    let editor = editor.clone();
    let debouncer = debouncer.clone();
    let storage = storage.clone();
    move |_| {
        let editor = editor.clone();  // Clone again for the inner closure
        let storage = storage.clone();
        debouncer.schedule(DEBOUNCE_DELAY, move || {
            storage.save(&editor.value());
        });
    }
});

Why clone twice? The outer closure is FnMut — it can be called multiple times (on every keystroke). Each call creates a new inner closure that needs its own owned data. If we moved without cloning, the first keystroke would consume the values.

Since we’re using Rc (reference counting), clones are cheap — just incrementing a counter.

The Debouncer

To avoid saving on every keystroke, we debounce the save operation:

use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;

const DEBOUNCE_DELAY: Duration = Duration::from_millis(250);

struct Debouncer {
    timeout_handle: Rc<RefCell<Option<i32>>>,
}

Two important Rust types here:

  • Rc (Reference Counted) — Allows multiple owners of the same data. In JavaScript, objects are automatically reference counted. In Rust, you need to opt in with Rc. We need this because multiple closures need to access the same debouncer.

  • RefCell — Enables interior mutability. Rust’s borrow checker normally prevents mutating data through shared references. RefCell moves the borrow checking to runtime, allowing mutation when you have shared ownership via Rc.

The pattern Rc<RefCell<T>> is common when you need shared mutable state in Rust, especially with callbacks.

impl Debouncer {
    fn schedule<F>(&self, delay: Duration, callback: F)
    where
        F: FnOnce() + 'static,
    {
        self.cancel();

        let handle_ref = self.timeout_handle.clone();

        let closure = Closure::once(Box::new(move || {
            callback();
            *handle_ref.borrow_mut() = None;
        }) as Box<dyn FnOnce()>);

        let handle = window()
            .set_timeout_with_callback_and_timeout_and_arguments_0(
                closure.as_ref().unchecked_ref(),
                delay.as_millis() as i32,
            )
            .expect("failed to set timeout");

        *self.timeout_handle.borrow_mut() = Some(handle);
        closure.forget();
    }
}

Note that we use Duration for the delay constant — it’s more semantic than a raw i32. The browser API still expects milliseconds as an integer, so we convert with delay.as_millis() as i32.

Multi-Tab Sync

Two mechanisms keep tabs in sync:

  1. storage event — Fires when another tab modifies localStorage:
add_event_listener(&window(), "storage", {
    let editor = editor.clone();
    move |event| {
        let storage_event = event.dyn_into::<web_sys::StorageEvent>().unwrap();
        if storage_event.key().as_deref() == Some(STORAGE_KEY) {
            if let Some(new_value) = storage_event.new_value() {
                editor.set_value(&new_value);
            }
        }
    }
});
  1. visibilitychange event — Save immediately when leaving the tab:
add_event_listener(&document(), "visibilitychange", {
    let editor = editor.clone();
    let debouncer = debouncer.clone();
    let storage = storage.clone();
    move |_| {
        if document().visibility_state() == web_sys::VisibilityState::Hidden {
            debouncer.cancel();
            storage.save(&editor.value());
        }
    }
});

The Modal (Plain JavaScript)

The info modal uses the native <dialog> element. We keep this in JavaScript rather than Rust because:

  • It’s pure UI chrome with no complex state
  • Plain JS is much simpler for this (~10 lines vs ~40 in Rust)
  • Modal handling doesn’t benefit from Rust’s type safety
<dialog id="info-modal">
    <article>
        <h1>ftextarea</h1>
        <p>Your content here...</p>
        <button id="close-modal">Close</button>
    </article>
</dialog>
// Modal handling (kept in JS since it's pure UI, no complex state)
const modal = document.getElementById('info-modal');
const infoBtn = document.getElementById('info-btn');
const closeBtn = document.getElementById('close-modal');

infoBtn.addEventListener('click', () => modal.showModal());
closeBtn.addEventListener('click', () => modal.close());
modal.addEventListener('click', (e) => {
    // Close on backdrop click (dialog element itself, not its children)
    if (e.target.tagName === 'DIALOG') modal.close();
});

Building

Install wasm-pack and build:

cargo install wasm-pack
wasm-pack build --target web

This generates a pkg/ directory with:

  • ftextarea_bg.wasm — The compiled WebAssembly binary
  • ftextarea.js — JavaScript bindings generated by wasm-bindgen

Loading in JavaScript

The entry point is simple:

import init from './pkg/ftextarea.js';

init().catch(err => {
    console.error('Failed to initialize:', err);
});

The init() function loads the WASM file, and our #[wasm_bindgen(start)] function runs automatically.

Offline Support

A Service Worker caches all static assets:

const CACHE_NAME = 'ftextarea-v1';
const STATIC_ASSETS = [
    '/', '/index.html', '/style.css', '/script.js',
    '/pkg/ftextarea.js', '/pkg/ftextarea_bg.wasm'
];

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(STATIC_ASSETS))
    );
});

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request)
            .then(cached => cached || fetch(event.request))
    );
});

Deployment

I deploy to a Scaleway server with Caddy. The release script:

  1. Builds WASM with wasm-pack
  2. Copies files to the server via scp
  3. Reloads Caddy
./release.sh

Conclusion

Building with vanilla Rust/WASM is more verbose than JavaScript, but you get:

  • Type safety across the entire stack
  • Rust’s error handling patterns
  • Learning actual Rust, not a framework’s abstraction

For a tiny app like this, the overhead isn’t worth it from a productivity standpoint. But as a learning exercise, it’s valuable to understand how the WASM glue code works before reaching for higher-level frameworks.

Try ftextarea →

Source code →