Tutorial de Tauri – Capítulo 10: Bases de datos con SQLite

Guardar datos en archivos de texto funciona para cosas simples, como la nota del capítulo anterior. Pero cuando necesitas almacenar muchos datos con estructura (usuarios, tareas, productos…), buscar entre ellos y filtrarlos, necesitas una base de datos.

SQLite es perfecta para aplicaciones de escritorio: es ligera, no necesita instalar ningún servidor, y toda la base de datos se guarda en un único archivo. Es la misma base de datos que usan WhatsApp, Firefox y muchas apps móviles.

SQL en 2 minutos

Si nunca has usado SQL, aquí va lo mínimo que necesitas saber. Una base de datos es como una hoja de cálculo: tiene tablas (como las hojas), y cada tabla tiene columnas (los campos) y filas (los datos). Para trabajar con los datos usas estas 4 operaciones:

OperaciónSQLEquivalente
CrearINSERT INTOAñadir una fila nueva
LeerSELECTBuscar y obtener filas
ActualizarUPDATEModificar una fila existente
EliminarDELETEBorrar una fila

Con esto es suficiente. Verás cada una en la práctica a medida que construimos el proyecto.

Crear el proyecto

Si tienes la aplicación anterior ejecutándose, párala con Ctrl+C. Vamos a crear un proyecto nuevo para hacer una lista de tareas con base de datos.

npm create tauri-app@latest

Responde a las preguntas:

  • Project name: tauri-tareas
  • Identifier: com.tutorial.tauritareas
  • Frontend language: TypeScript / JavaScript
  • Package manager: npm
  • UI template: React
  • UI flavor: JavaScript

Entra en el proyecto e instala las dependencias:

cd tauri-tareas
npm install

Instalar el plugin SQL

Instala el plugin de SQL con este comando:

npm run tauri add sql

Ahora necesitas verificar que la dependencia de Rust incluya el soporte para SQLite. Abre src-tauri/Cargo.toml y busca la línea de tauri-plugin-sql. Asegúrate de que tenga features = ["sqlite"]:

tauri-plugin-sql = { version = "2", features = ["sqlite"] }

Si solo pone tauri-plugin-sql = "2" sin los features, cámbialo a la línea de arriba.

Ahora verifica que el plugin esté registrado en src-tauri/src/lib.rs. Debería tener esta línea dentro de la función run():

.plugin(tauri_plugin_sql::Builder::default().build())

Si no la ves, añádela. Tu función run() debería quedar parecida a esto:

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_sql::Builder::default().build())
        .plugin(tauri_plugin_opener::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Configurar permisos

Abre src-tauri/capabilities/default.json y comprueba que el permiso "sql:default" esté en la lista de permissions. Si el comando anterior lo añadió automáticamente, ya lo tendrás. Si no, añádelo tú. Además, necesitas añadir dos permisos extra: "sql:allow-execute" para poder crear tablas e insertar datos, y "sql:allow-select" para poder consultar datos. El archivo debería quedar así:

{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",
    "sql:default",
    "sql:allow-execute",
    "sql:allow-select"
  ]
}

Construir la lista de tareas

Vamos a construir una lista de tareas que permita:

  • Añadir tareas nuevas
  • Marcar tareas como completadas (y desmarcarlas)
  • Eliminar tareas
  • Que los datos se guarden permanentemente en la base de datos

Todo el código va en un solo componente para que sea fácil de seguir. Crea el archivo src/ListaTareas.jsx:

import { useState, useEffect } from "react";
import Database from "@tauri-apps/plugin-sql";

function ListaTareas() {
  const [db, setDb] = useState(null);
  const [tareas, setTareas] = useState([]);
  const [nuevoTitulo, setNuevoTitulo] = useState("");

  // Al abrir la app, conectar a la base de datos
  useEffect(() => {
    let cancelado = false;

    async function iniciarDB() {
      // Conectar a SQLite (crea el archivo si no existe)
      const database = await Database.load("sqlite:tareas.db");

      // Crear la tabla si no existe
      await database.execute(`
        CREATE TABLE IF NOT EXISTS tareas (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          titulo TEXT NOT NULL,
          completada INTEGER DEFAULT 0
        )
      `);

      if (cancelado) return;

      setDb(database);

      // Cargar las tareas existentes
      const resultado = await database.select(
        "SELECT * FROM tareas ORDER BY id DESC"
      );
      if (!cancelado) setTareas(resultado);
    }

    iniciarDB();

    return () => { cancelado = true; };
  }, []);

  async function cargarTareas() {
    if (!db) return;
    const resultado = await db.select(
      "SELECT * FROM tareas ORDER BY id DESC"
    );
    setTareas(resultado);
  }

  async function agregarTarea(e) {
    e.preventDefault();
    if (!db || !nuevoTitulo.trim()) return;

    await db.execute(
      "INSERT INTO tareas (titulo) VALUES ($1)",
      [nuevoTitulo]
    );
    setNuevoTitulo("");
    await cargarTareas();
  }

  async function toggleTarea(id, completadaActual) {
    if (!db) return;
    await db.execute(
      "UPDATE tareas SET completada = $1 WHERE id = $2",
      [completadaActual ? 0 : 1, id]
    );
    await cargarTareas();
  }

  async function eliminarTarea(id) {
    if (!db) return;
    await db.execute(
      "DELETE FROM tareas WHERE id = $1",
      [id]
    );
    await cargarTareas();
  }

  if (!db) return <p>Cargando base de datos...</p>;

  return (
    <div style={{ maxWidth: "600px", margin: "0 auto", padding: "2rem" }}>
      <h1>Lista de Tareas</h1>

      <form onSubmit={agregarTarea} style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
        <input
          value={nuevoTitulo}
          onChange={(e) => setNuevoTitulo(e.target.value)}
          placeholder="Nueva tarea..."
          style={{
            flex: 1,
            padding: "0.8rem",
            borderRadius: "8px",
            border: "1px solid #444",
            background: "#2a2a2a",
            color: "white",
            fontSize: "1rem",
          }}
        />
        <button
          type="submit"
          style={{
            padding: "0.8rem 1.5rem",
            background: "#24c8db",
            color: "#1a1a1a",
            border: "none",
            borderRadius: "8px",
            fontWeight: "bold",
            cursor: "pointer",
          }}
        >
          Añadir
        </button>
      </form>

      {tareas.length === 0 ? (
        <p style={{ color: "#888" }}>No hay tareas. Añade una para empezar.</p>
      ) : (
        <ul style={{ listStyle: "none", padding: 0 }}>
          {tareas.map((tarea) => (
            <li
              key={tarea.id}
              style={{
                display: "flex",
                alignItems: "center",
                gap: "0.5rem",
                padding: "0.8rem",
                background: "#2a2a2a",
                borderRadius: "8px",
                marginBottom: "0.5rem",
              }}
            >
              <span
                onClick={() => toggleTarea(tarea.id, tarea.completada)}
                style={{
                  flex: 1,
                  textDecoration: tarea.completada ? "line-through" : "none",
                  color: tarea.completada ? "#888" : "white",
                  cursor: "pointer",
                }}
              >
                {tarea.completada ? "☑" : "☐"} {tarea.titulo}
              </span>
              <button
                onClick={() => eliminarTarea(tarea.id)}
                style={{
                  background: "#e74c3c",
                  color: "white",
                  border: "none",
                  borderRadius: "4px",
                  padding: "0.3rem 0.8rem",
                  cursor: "pointer",
                }}
              >
                Eliminar
              </button>
            </li>
          ))}
        </ul>
      )}

      <p style={{ color: "#888", fontSize: "0.9rem" }}>
        {tareas.length} tarea{tareas.length !== 1 ? "s" : ""} en total
      </p>
    </div>
  );
}

export default ListaTareas;

Es bastante código, pero todo tiene sentido. Vamos a ver qué hace cada parte:

Conexión y creación de la tabla

  • let cancelado = false y return () => { cancelado = true; }: esto evita que React ejecute la inicialización dos veces en modo desarrollo. Es un patrón estándar de React para limpiar efectos.
  • Database.load("sqlite:tareas.db"): conecta a la base de datos SQLite. El prefijo sqlite: indica el tipo. Si el archivo tareas.db no existe, lo crea automáticamente en la carpeta de datos de tu aplicación.
  • CREATE TABLE IF NOT EXISTS tareas (...): crea la tabla si no existe. Así la app funciona tanto la primera vez (tabla nueva) como las siguientes (tabla existente con datos).
  • id INTEGER PRIMARY KEY AUTOINCREMENT: cada tarea tiene un ID numérico que se genera solo.
  • titulo TEXT NOT NULL: el texto de la tarea, obligatorio.
  • completada INTEGER DEFAULT 0: 0 = pendiente, 1 = completada. Por defecto, pendiente.

Operaciones con datos

  • db.execute("INSERT INTO tareas (titulo) VALUES ($1)", [nuevoTitulo]): añade una tarea nueva. El $1 es un parámetro que se sustituye por el valor del array. Usar parámetros en vez de concatenar strings previene ataques de inyección SQL.
  • db.select("SELECT * FROM tareas ORDER BY id DESC"): obtiene todas las tareas ordenadas de más nueva a más antigua. Devuelve un array de objetos.
  • db.execute("UPDATE tareas SET completada = $1 WHERE id = $2", [valor, id]): cambia el estado de completada de la tarea con ese ID.
  • db.execute("DELETE FROM tareas WHERE id = $1", [id]): elimina la tarea con ese ID.

La interfaz

  • El formulario llama a agregarTarea al pulsar «Añadir» o presionar Enter.
  • Al hacer clic en el texto de una tarea, se marca/desmarca como completada (toggleTarea). Las completadas aparecen tachadas.
  • El botón «Eliminar» borra la tarea de la base de datos.
  • Después de cada operación, se llama a cargarTareas() para refrescar la lista desde la base de datos.

Ahora abre src/App.jsx y reemplaza todo su contenido por:

import ListaTareas from "./ListaTareas";

function App() {
  return <ListaTareas />;
}

export default App;

Ejecuta la aplicación:

npm run tauri dev

Añade algunas tareas, marca alguna como completada, elimina otra. Ahora cierra la aplicación y vuelve a abrirla — todas las tareas siguen ahí, guardadas en la base de datos SQLite.

Referencia rápida de SQL

Estas son las operaciones SQL que puedes usar con el plugin. Todas se ejecutan con db.execute() (para escribir) o db.select() (para leer):

// Insertar
await db.execute(
  "INSERT INTO tabla (campo1, campo2) VALUES ($1, $2)",
  ["valor1", "valor2"]
);

// Seleccionar todo
const filas = await db.select("SELECT * FROM tabla");

// Seleccionar con filtro
const filtradas = await db.select(
  "SELECT * FROM tabla WHERE campo1 = $1",
  ["valor"]
);

// Actualizar
await db.execute(
  "UPDATE tabla SET campo1 = $1 WHERE id = $2",
  ["nuevo valor", id]
);

// Eliminar
await db.execute(
  "DELETE FROM tabla WHERE id = $1",
  [id]
);

// Contar filas
const resultado = await db.select(
  "SELECT COUNT(*) as total FROM tabla"
);
const total = resultado[0].total;

Resumen

En este capítulo has aprendido a:

  • Instalar el plugin SQL con npm run tauri add sql
  • Conectar a una base de datos SQLite con Database.load()
  • Crear tablas con CREATE TABLE IF NOT EXISTS
  • Insertar datos con INSERT INTO
  • Consultar datos con SELECT
  • Actualizar datos con UPDATE
  • Eliminar datos con DELETE
  • Usar parámetros ($1, $2) para prevenir inyección SQL

En el próximo capítulo aprenderás a crear menús nativos, bandejas del sistema y diálogos.

Nos vemos en el Capítulo 11.


¿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