Mutating Data with Actions
We’ve talked about how to load async
data with resources. Resources immediately load data and work closely with <Suspense/>
and <Transition/>
components to show whether data is loading in your app. But what if you just want to call some arbitrary async
function and keep track of what it’s doing?
Well, you could always use spawn_local
. This allows you to just spawn an async
task in a synchronous environment by handing the Future
off to the browser (or, on the server, Tokio or whatever other runtime you’re using). But how do you know if it’s still pending? Well, you could just set a signal to show whether it’s loading, and another one to show the result...
All of this is true. Or you could use the final async
primitive: Action
.
Actions and resources seem similar, but they represent fundamentally different things. If you’re trying to load data by running an async
function, either once or when some other value changes, you probably want to use a resource. If you’re trying to occasionally run an async
function in response to something like a user clicking a button, you probably want to use an Action
.
Say we have some async
function we want to run.
async fn add_todo_request(new_title: &str) -> Uuid {
/* do some stuff on the server to add a new todo */
}
Action::new()
takes an async
function that takes a reference to a single argument, which you could think of as its “input type.”
The input is always a single type. If you want to pass in multiple arguments, you can do it with a struct or tuple.
// if there's a single argument, just use that let action1 = Action::new(|input: &String| { let input = input.clone(); async move { todo!() } }); // if there are no arguments, use the unit type `()` let action2 = Action::new(|input: &()| async { todo!() }); // if there are multiple arguments, use a tuple let action3 = Action::new( |input: &(usize, String)| async { todo!() } );
Because the action function takes a reference but the
Future
needs to have a'static
lifetime, you’ll usually need to clone the value to pass it into theFuture
. This is admittedly awkward but it unlocks some powerful features like optimistic UI. We’ll see a little more about that in future chapters.
So in this case, all we need to do to create an action is
let add_todo_action = Action::new(|input: &String| {
let input = input.to_owned();
async move { add_todo_request(&input).await }
});
Rather than calling add_todo_action
directly, we’ll call it with .dispatch()
, as in
add_todo_action.dispatch("Some value".to_string());
You can do this from an event listener, a timeout, or anywhere; because .dispatch()
isn’t an async
function, it can be called from a synchronous context.
Actions provide access to a few signals that synchronize between the asynchronous action you’re calling and the synchronous reactive system:
let submitted = add_todo_action.input(); // RwSignal<Option<String>>
let pending = add_todo_action.pending(); // ReadSignal<bool>
let todo_id = add_todo_action.value(); // RwSignal<Option<Uuid>>
This makes it easy to track the current state of your request, show a loading indicator, or do “optimistic UI” based on the assumption that the submission will succeed.
let input_ref = NodeRef::<Input>::new();
view! {
<form
on:submit=move |ev| {
ev.prevent_default(); // don't reload the page...
let input = input_ref.get().expect("input to exist");
add_todo_action.dispatch(input.value());
}
>
<label>
"What do you need to do?"
<input type="text"
node_ref=input_ref
/>
</label>
<button type="submit">"Add Todo"</button>
</form>
// use our loading state
<p>{move || pending.get().then_some("Loading...")}</p>
}
Now, there’s a chance this all seems a little over-complicated, or maybe too restricted. I wanted to include actions here, alongside resources, as the missing piece of the puzzle. In a real Leptos app, you’ll actually most often use actions alongside server functions, ServerAction
, and the <ActionForm/>
component to create really powerful progressively-enhanced forms. So if this primitive seems useless to you... Don’t worry! Maybe it will make sense later. (Or check out our todo_app_sqlite
example now.)
Live example
CodeSandbox Source
use gloo_timers::future::TimeoutFuture;
use leptos::{html::Input, prelude::*};
use uuid::Uuid;
// Here we define an async function
// This could be anything: a network request, database read, etc.
// Think of it as a mutation: some imperative async action you run,
// whereas a resource would be some async data you load
async fn add_todo(text: &str) -> Uuid {
_ = text;
// fake a one-second delay
// SendWrapper allows us to use this !Send browser API; don't worry about it
send_wrapper::SendWrapper::new(TimeoutFuture::new(1_000)).await;
// pretend this is a post ID or something
Uuid::new_v4()
}
#[component]
pub fn App() -> impl IntoView {
// an action takes an async function with single argument
// it can be a simple type, a struct, or ()
let add_todo = Action::new(|input: &String| {
// the input is a reference, but we need the Future to own it
// this is important: we need to clone and move into the Future
// so it has a 'static lifetime
let input = input.to_owned();
async move { add_todo(&input).await }
});
// actions provide a bunch of synchronous, reactive variables
// that tell us different things about the state of the action
let submitted = add_todo.input();
let pending = add_todo.pending();
let todo_id = add_todo.value();
let input_ref = NodeRef::<Input>::new();
view! {
<form
on:submit=move |ev| {
ev.prevent_default(); // don't reload the page...
let input = input_ref.get().expect("input to exist");
add_todo.dispatch(input.value());
}
>
<label>
"What do you need to do?"
<input type="text"
node_ref=input_ref
/>
</label>
<button type="submit">"Add Todo"</button>
</form>
<p>{move || pending.get().then_some("Loading...")}</p>
<p>
"Submitted: "
<code>{move || format!("{:#?}", submitted.get())}</code>
</p>
<p>
"Pending: "
<code>{move || format!("{:#?}", pending.get())}</code>
</p>
<p>
"Todo ID: "
<code>{move || format!("{:#?}", todo_id.get())}</code>
</p>
}
}
fn main() {
leptos::mount::mount_to_body(App)
}