Tutorial de Tauri – Capítulo 7: Commands – Comunicación de JavaScript a Rust

Aquí es donde Tauri se vuelve realmente poderoso. Ahora que conoces las bases de Rust, vamos a usarlas: los commands te permiten llamar funciones de Rust desde JavaScript. Esto significa que puedes ejecutar código nativo de alto rendimiento desde tu interfaz web.

¿Necesitas leer un archivo pesado? ¿Hacer cálculos intensivos? ¿Acceder a APIs del sistema? Lo haces en Rust y lo llamas desde JavaScript.

Todos los conceptos de Rust que usaremos aquí (format!, match, Result, structs…) los vimos en el capítulo anterior. Si algo no te suena, vuelve al Capítulo 6 para repasarlo.

Tu primer Command

Abre src-tauri/src/lib.rs y vamos a crear un command sencillo:

#[tauri::command]
fn saludar(nombre: String) -> String {
    format!("¡Hola, {}! Este saludo viene de Rust.", nombre)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![saludar])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Analicemos lo que hemos hecho:

  • #[tauri::command]: Este atributo convierte una función Rust en un command accesible desde JavaScript
  • fn saludar(nombre: String) -> String: Una función que recibe un String y devuelve otro
  • invoke_handler(tauri::generate_handler![saludar]): Registra el command para que esté disponible

Importante: Los commands en lib.rs no deben marcarse como pub (es una limitación del generador de código).

Llamar al Command desde JavaScript

Abre el archivo src/Greeting.jsx (el componente que creamos en el capítulo anterior) y modifícalo para llamar al command de Rust:

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";

function Greeting() {
  const [name, setName] = useState("");
  const [greeting, setGreeting] = useState("");

  async function handleSubmit(e) {
    e.preventDefault();
    // Llamamos al command de Rust
    const response = await invoke("saludar", { nombre: name });
    setGreeting(response);
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Tu nombre"
        />
        <button type="submit">Saludar desde Rust</button>
      </form>
      {greeting && <p>{greeting}</p>}
    </div>
  );
}

export default Greeting;

La función invoke recibe dos argumentos:

  1. El nombre del command (en snake_case)
  2. Un objeto con los parámetros (las claves deben coincidir con los nombres en Rust)

Si ejecutas la aplicación con npm run tauri dev, escribes tu nombre y pulsas el botón, verás algo como esto:

Commands con múltiples parámetros

Vamos a crear algo más interesante: una calculadora. Añade este nuevo command en src-tauri/src/lib.rs, justo encima de la función run():

#[tauri::command]
fn calcular(a: f64, b: f64, operacion: String) -> Result<f64, String> {
    match operacion.as_str() {
        "sumar" => Ok(a + b),
        "restar" => Ok(a - b),
        "multiplicar" => Ok(a * b),
        "dividir" => {
            if b == 0.0 {
                Err("No se puede dividir por cero".to_string())
            } else {
                Ok(a / b)
            }
        }
        _ => Err(format!("Operación desconocida: {}", operacion)),
    }
}

Veamos qué hace este código:

  • Result<f64, String>: la función puede devolver un número (Ok) o un mensaje de error (Err). Es como si en JavaScript pudieras devolver el resultado o lanzar un error, pero de forma explícita.
  • match operacion.as_str(): funciona como un switch de JavaScript. Comprueba el valor de operacion y ejecuta la rama correspondiente.
  • Ok(a + b): devuelve el resultado exitoso.
  • Err("..."): devuelve un error (por ejemplo, si intentas dividir por cero).
  • _ =>: es el caso por defecto, como el default de un switch. Se ejecuta si ninguna otra rama coincide.

Ahora registra el nuevo command calcular en la función run() de src-tauri/src/lib.rs. Fíjate en que ahora el generate_handler! incluye los dos commands: saludar y calcular. Tu función run() completa debería quedar así:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![saludar, calcular])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Ahora crea el componente React. Crea un nuevo archivo src/Calculator.jsx:

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";

function Calculator() {
  const [a, setA] = useState("");
  const [b, setB] = useState("");
  const [resultado, setResultado] = useState("");

  async function operar(operacion) {
    try {
      const res = await invoke("calcular", {
        a: parseFloat(a),
        b: parseFloat(b),
        operacion: operacion,
      });
      setResultado("Resultado: " + res);
    } catch (error) {
      setResultado("Error: " + error);
    }
  }

  return (
    <div>
      <h2>Calculadora Rust</h2>
      <input
        type="number"
        value={a}
        onChange={(e) => setA(e.target.value)}
        placeholder="Número A"
      />
      <input
        type="number"
        value={b}
        onChange={(e) => setB(e.target.value)}
        placeholder="Número B"
      />
      <div>
        <button onClick={() => operar("sumar")}>Sumar</button>
        <button onClick={() => operar("restar")}>Restar</button>
        <button onClick={() => operar("multiplicar")}>Multiplicar</button>
        <button onClick={() => operar("dividir")}>Dividir</button>
      </div>
      {resultado && <p>{resultado}</p>}
    </div>
  );
}

export default Calculator;

Para verlo en acción, añade el componente <Calculator /> en src/App.jsx:

import Greeting from "./Greeting";
import Calculator from "./Calculator";

function App() {
  return (
    <div className="container">
      <h1>Mi App Tauri + React</h1>
      <Greeting />
      <Calculator />
    </div>
  );
}

export default App;

Ejecuta npm run tauri dev, escribe dos números, pulsa un botón de operación y verás el resultado. Prueba a dividir por cero para ver cómo Rust devuelve el error y JavaScript lo captura con el catch.

Tipos de datos soportados

La comunicación entre JavaScript y Rust usa serialización JSON con Serde:

JavaScriptRust
numberi32, i64, f32, f64
stringString
booleanbool
arrayVec<T>
objectstruct con Serialize/Deserialize
nullOption<T>

Commands con structs

Hasta ahora hemos pasado datos simples (números, strings). Pero en una aplicación real necesitarás pasar objetos completos entre JavaScript y Rust. Para eso se usan los structs, que son el equivalente en Rust a los objetos de JavaScript.

Añade este código en src-tauri/src/lib.rs, al principio del archivo (antes de los commands):

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Usuario {
    nombre: String,
    edad: u32,
    email: String,
}

Veamos qué hace cada línea:

  • use serde::{Deserialize, Serialize}: importa las herramientas para convertir el struct a/desde JSON. Esto es lo que permite que JavaScript y Rust se entiendan.
  • #[derive(Serialize, Deserialize)]: le dice a Rust «genera automáticamente el código para convertir este struct a JSON y viceversa».
  • struct Usuario { ... }: define la estructura con sus campos y tipos. Es como definir la forma de un objeto en JavaScript, pero con tipos obligatorios.

En JavaScript, este struct equivale a un objeto con esta forma:

// JavaScript equivalente
const usuario = {
  nombre: "Eduardo",  // string
  edad: 30,            // number
  email: "edu@email.com" // string
};

Ahora añade estos dos commands en src-tauri/src/lib.rs, debajo del struct:

#[tauri::command]
fn procesar_usuario(usuario: Usuario) -> String {
    format!(
        "Usuario {} tiene {} años. Email: {}",
        usuario.nombre, usuario.edad, usuario.email
    )
}

#[tauri::command]
fn obtener_usuario() -> Usuario {
    Usuario {
        nombre: "Juan".to_string(),
        edad: 30,
        email: "juan@email.com".to_string(),
    }
}

El primero (procesar_usuario) recibe un objeto desde JavaScript y devuelve un texto. El segundo (obtener_usuario) hace lo contrario: crea un objeto en Rust y se lo envía a JavaScript.

Registra los dos nuevos commands procesar_usuario y obtener_usuario en la función run() de src-tauri/src/lib.rs:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![saludar, calcular, procesar_usuario, obtener_usuario])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Ahora crea el componente src/UserCard.jsx para probarlo:

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";

function UserCard() {
  const [nombre, setNombre] = useState("");
  const [edad, setEdad] = useState("");
  const [email, setEmail] = useState("");
  const [resultado, setResultado] = useState("");

  async function enviarUsuario(e) {
    e.preventDefault();
    const respuesta = await invoke("procesar_usuario", {
      usuario: {
        nombre: nombre,
        edad: parseInt(edad),
        email: email,
      },
    });
    setResultado(respuesta);
  }

  async function cargarUsuario() {
    const usuario = await invoke("obtener_usuario");
    setNombre(usuario.nombre);
    setEdad(String(usuario.edad));
    setEmail(usuario.email);
    setResultado("Usuario cargado desde Rust");
  }

  return (
    <div>
      <h2>Ficha de usuario</h2>
      <button onClick={cargarUsuario}>Cargar usuario desde Rust</button>
      <form onSubmit={enviarUsuario}>
        <input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Nombre" />
        <input type="number" value={edad} onChange={(e) => setEdad(e.target.value)} placeholder="Edad" />
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
        <button type="submit">Enviar a Rust</button>
      </form>
      {resultado && <p>{resultado}</p>}
    </div>
  );
}

export default UserCard;

Añade el componente <UserCard /> en src/App.jsx:

import Greeting from "./Greeting";
import Calculator from "./Calculator";
import UserCard from "./UserCard";

function App() {
  return (
    <div className="container">
      <h1>Mi App Tauri + React</h1>
      <Greeting />
      <Calculator />
      <UserCard />
    </div>
  );
}

export default App;

Ejecuta npm run tauri dev y prueba las dos funciones: pulsa «Cargar usuario desde Rust» para ver cómo Rust envía un objeto a JavaScript, y rellena el formulario y pulsa «Enviar a Rust» para ver cómo JavaScript envía un objeto a Rust.

Manejo de errores con Result

Los commands que pueden fallar deben devolver Result. En src-tauri/src/lib.rs:

#[tauri::command]
fn operacion_riesgosa(valor: i32) -> Result<String, String> {
    if valor < 0 {
        Err("El valor no puede ser negativo".to_string())
    } else {
        Ok(format!("Valor procesado: {}", valor))
    }
}

Y en tu componente React, usa try/catch para capturar el error:

try {
  const resultado = await invoke("operacion_riesgosa", { valor: -5 });
  console.log(resultado);
} catch (error) {
  console.error("Error desde Rust:", error);
  // "El valor no puede ser negativo"
}

Commands asíncronos

Cuando un command necesita hacer operaciones que tardan (leer archivos, consultar bases de datos, llamadas HTTP…), se marca como asíncrono con async. Los usaremos mucho en los próximos capítulos, pero por ahora solo necesitas conocer la sintaxis. En src-tauri/src/lib.rs se vería así:

#[tauri::command]
async fn operacion_lenta() -> String {
    // Simular trabajo
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
    "Operación completada".to_string()
}

Necesitas añadir tokio a las dependencias en src-tauri/Cargo.toml (no lo hagas ahora, es solo para que lo tengas como referencia):

[dependencies]
tokio = { version = "1", features = ["time"] }

Acceder al AppHandle y Window

Los commands pueden acceder a recursos de Tauri automáticamente. En src-tauri/src/lib.rs:

#[tauri::command]
async fn obtener_info_ventana(window: tauri::WebviewWindow) -> String {
    format!("Ventana: {}", window.label())
}

#[tauri::command]
async fn usar_app_handle(app: tauri::AppHandle) {
    // Acceder a paths, configuración, etc.
    let _ = app.path();
}

Tauri inyecta estos parámetros automáticamente, no necesitas pasarlos desde JavaScript.

Resumen

Los commands son el puente entre tu frontend y Rust:

  • Se definen con #[tauri::command]
  • Se registran con invoke_handler
  • Se llaman con invoke() desde JavaScript
  • Soportan parámetros, structs, Result y async

En el próximo capítulo veremos los eventos, que permiten comunicación bidireccional en tiempo real.

Nos vemos en el Capítulo 8.


¿Te está gustando este tutorial?

Este tutorial forma parte del libro Tauri 2.0: Aplicaciones de Escritorio con React y Rust, disponible en Amazon en formato papel y ebook. El libro incluye todos los capítulos, tres proyectos completos paso a paso y contenido exclusivo que no encontrarás en el blog.

Un saludo, y si aún no lo has hecho no olvides suscribirte a mi blog para no perderte los próximos posts  :-),

También puedes seguirme en Twitter en ‎@revi_apps y no olvides que me ayudas mucho si compartes este post en las redes sociales.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Scroll al inicio