Made a tiny virtual DOM based toy web framework called Respo.rs

It looks like:

I have years of experience using React.js and ClojureScript. And Respo was the virtual DOM library I created in ClojureScript http://cljs.respo-mvc.org/ . Now I try to make Rust version of Respo and finally finished a prototype of Respo.rs ,

A quick overview of features:

  • virtual DOM(however simplified in list diffing)
  • components declaration with functions
  • globals states with Store and Actions dispatching
  • cursor-based states tree(inherited from Respo.cljs , might be awkward without hot reloading)
  • CSS in Rust macros
  • basic component effects of Mounted, WillUpdate, Updated, WillUnmount
  • macros to memoize component functions(quite clumsy code)

features that not implemented in Respo.rs :

  • :x: macros for JSX syntax. Respo expects smooth type checking and autocompletion, which might be broken in JSX macros.(it's also hard to implement)
  • :x: updating component states in lifecycles. Respo enforces “unidirectional data flow”, you can only update DOM inside lifecycle closures
  • :x: React-like hooks. Respo uses functions without tricky side-effects, which restricted the usages of hooks.
  • :x: Hot reloading. It does not work in WebAssembly so far

I hold an opinion that hot reloading is an essential feature for virtual DOM based web frameworks. Unfortunately Rust/WASM does not support it. So Respo.rs is far from satisfaction comparing to it's previous versions in compile-to-js languages.

The shinny part of Respo.rs is the power of Rust type systems that abstracts store/states and dispatchers. That's why I still feel glad to finish this library.

A very simple Respo.rs component looks like:

pub fn comp_demo() -> Result<RespoNode<ActionOp>, String> {
  Ok(div().class("parent").children([
    span().class(style_of_text()).inner_text("Demo component").to_owned()
  ]).to_owned())
}

to define some static styles(attached to <style />) with a macro:

static_styles!(
  style_of_text,
  (
    "$0".to_owned(),
    RespoStyle::default()
      .margin(4.)
      .color(CssColor::Hsl(0, 90, 90)),
  ),
  ("$0:hover".to_owned(), RespoStyle::default().color(CssColor::Hsl(0, 90, 80))),
);

or use inline styles:

span().inner_text("demo")
  .style(RespoStyle::default().color(Css::Color::Blue))
  .to_owned()

or use conditional class names:

span()
  .toggle_class("selected", status == Status::On)
  .maybe_class(if tab == Tab::A { Some("highlighted") } else { None })
  .to_owned()

The syntax is quite different from Yew, which uses JSX macros. Respo.rs relies heavily on methods to provide sugars and autocompletion everywhere. I'm not exactly certain if that's best choice for Respo or whether I need to change it in future versions.

Defining global store and actions is easy, just some data abstractions plus traits:

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Store {
  pub todos: Vec<Task>,
  pub states: StatesTree,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ActionOp {
  StatesChange(Vec<String>, MaybeState),
  AddTodo(String, String),
  ToggleAll,
  Toggle(String),
  // more...
}

impl RespoAction for ActionOp {
  fn wrap_states_action(cursor: &[String], a: MaybeState) -> Self {
    Self::StatesChange(cursor.to_vec(), a)
  }
}

impl RespoStore for Store {
  type Action = ActionOp;

  fn get_states(&self) -> StatesTree {
    self.states.to_owned()
  }
  fn update(&mut self, op: Self::Action) -> Result<(), String> {
    match op {
      ActionOp::StatesChange(path, new_state) => {
        self.states.set_in_mut(&path, new_state);
      }
      ActionOp::AddTodo(id, content) => self.todos.push(Task {
        id,
        title: content,
        completed: false,
      }),
      ActionOp::ToggleAll => {
        let completed = self.todos.iter().all(|t| t.completed);
        for t in &mut self.todos {
          t.completed = !completed;
        }
      }
      // more...
    }
    Ok(())
  }
}

However, defining component states looks a bit boring in Respo.rs since it does not have "real local mutable states". In Respo it's using a cursor-based design that maintains states in a global tree, and each components picks its own branch with states.pick("name") level by level and updates it with a cursor.

A rough example split from TodoMVC looks like:

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
struct TaskState {
  edit_text: String,
}

fn comp_demo(states: &StatesTree) -> Result<_, String> -> {
  // load from states tree
  let cursor = states.path();
  let state: TaskState = states.data.cast_or_default()?;

  // partial code from demo, need to share to multiple closures
  let cursor2 = cursor.clone();
  let state2 = state.clone();

  let handle_blur = move |_e, dispatch: DispatchFn<_>| -> Result<(), String> { handle_submit2(dispatch) };

  let handle_keydown = move |e, dispatch: DispatchFn<_>| -> Result<(), String> {
    if let RespoEvent::Keyboard { key_code, .. } = e {
      if key_code == 13 {
        handle_submit3(dispatch)?;
      } else if key_code == 27 {
        on_cancel3(dispatch)?;
      }
    }
    Ok(())
  };

  let handle_change = move |e, dispatch: DispatchFn<_>| -> Result<(), String> {
    if let RespoEvent::Input { value, .. } = e {
      dispatch.run_state(&cursor, TaskState { edit_text: value })?;
    }
    Ok(())
  };

  Ok(
    input()
      .class("edit")
      .attribute("value", state.edit_text)
      .on_input(handle_change)
      .on_keydown(handle_keydown)
      .on_named_event("blur", handle_blur)
      .to_owned()
  )
}

and to call it with picked branch of states:

comp_demo(states.pick("demo"))?

I'm afraid the snippets are too fragile spit out from project. Better to browse the demo project if it appeals to you:

and here's how it runs in a browser TodoMVC Respo.rs .

and the doc:

By now it's a toy for experiment. I'm also glad it's providing a different feature set from Yew so that Respo.rs may explore in a different mileage.

1 Like

I also want to highlight some problems in Respo.rs , if there are any suggestions.

type tricks for states tree.

States tree is a tree structure, with data field storing real data.

https://github.com/Respo/respo.rs/blob/main/src/respo/states_tree.rs#L10-L18

https://github.com/Respo/respo.rs/blob/main/src/respo/states_tree.rs#L65-L67

Now it's defined in Value from serde_json, which means it's more like a dynamic data type. However at component level, it's a struct, for example struct TaskState { .. }. I cannot save TaskState to StatesTree directly since it's defined outside and can be any struct(that obeys Serialize).

So I added 2 functions to convert Value from and back to TaskState with help of serde. I also explored dynamic trait objects, which made it hard to implement PartialEq, failed. Is there better design for such cases in Rust?

Same problem in storing arguments for effects.

clone for Closures

For event handlers in Respo.rs components, when local data is referenced, I have to use Rc and use data.clone(), a lot. I also observed similar pattern in Yew that clones the data in order to share it to closures.

Maybe it's not a good question but I do want to try if there are other possibilities for writing closures.

Hot reload for WASM

Called "how module replacement" in Webpack, "hot reload" in ClojureScript. That's very useful in developing UIs without breaking pages states, or DOM states. Any explorations happened that could make it possible?

Re-render loop and borrowed error

currently in Respo.rs :

  • render function depends on virtual DOM
  • virtual DOM tree depends on event handler
  • event handler triggers re-rendering, so it also depends on render function

To decouple the dead loop, I added a requestAnimationFrame loop to check an extra need_re_render state. It works, but with extra costs to have such a loop.

I also tried Rc<RefCell<T>> over render function, using mutable way to break the loop, however it ended with a "already borrowed" error, since call stack is recursive and somehow one borrow was not dropped when I call r.replace(...). Is there any example that I could read and learn to fix this? Or is it even possible in Rust in a browser?

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.