← Table of contents

8.2 One State, Two Values

A correspondence where one writes and the other only reads is not correspondence. The command line writes to the ledger. The server reads from it at startup. What is added through the server never reaches the ledger - it disappears when the process ends. Two entries, but only one of them leaves a mark.

What You Need

The handlers can see the store. The file path, they cannot: it stayed in main and went no further. Without it, saving is impossible.

The solution is direct: bundle the store and the path into one struct and pass that instead of the store alone. Axum will deliver it to every handler through State - the same way it delivered SharedStore before. The one condition: the struct must implement Clone, because axum clones state on every call. #[derive(Clone)] is enough - both fields already implement it.

The Build

In crates/api/src/routes.rs, AppState appears and persistence is imported:

// crates/api/src/routes.rs - CHANGED
use axum::{
    Json, Router,
    extract::{Path, State},
    routing::{get, patch, post},
};
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tq_core::persistence;
use tq_core::store::{Store, TaskStore};
use tq_core::task::Task;

pub type SharedStore = Arc<Mutex<TaskStore>>;

#[derive(Clone)] // axum clones state on every call
pub struct AppState {
    pub store: SharedStore,
    pub data_path: PathBuf,
}

#[derive(Deserialize)]
struct AddRequest {
    title: String,
}

async fn add(State(state): State<AppState>, Json(req): Json<AddRequest>) -> Json<Task> {
    let mut guard = state.store.lock().unwrap();
    let id = guard.add(req.title.as_str()).unwrap();
    persistence::save(guard.all(), &state.data_path).unwrap();
    Json(guard.get(id).unwrap().clone())
}

async fn list(State(state): State<AppState>) -> Json<Vec<Task>> {
    let guard = state.store.lock().unwrap();
    Json(guard.all().to_vec())
}

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Json<Task> {
    let guard = state.store.lock().unwrap();
    Json(guard.get(id).unwrap().clone())
}

async fn done(State(state): State<AppState>, Path(id): Path<u64>) -> Json<Task> {
    let mut guard = state.store.lock().unwrap();
    let task = guard.get_mut(id).unwrap();
    task.complete();
    let task = task.clone(); // ends the mutable borrow - guard.all() requires an immutable one
    persistence::save(guard.all(), &state.data_path).unwrap();
    Json(task)
}

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/tasks", post(add).get(list))
        .route("/tasks/{id}", get(get_task))
        .route("/tasks/{id}/done", patch(done))
        .with_state(state)
}

In add, persistence::save comes right before the return. In done it is slightly different: task is a mutable reference from guard, and while it lives guard.all() cannot be taken - two borrows on the same guard at once. let task = task.clone() creates an owned value and ends the mutable borrow; after that, guard.all() is free.

The lock is held through the end of save in both handlers - intentionally. If released earlier, two concurrent requests could save different snapshots and the older one would overwrite the newer. For tq, that is real data loss.

In crates/api/src/main.rs - assembling AppState from its two parts:

// crates/api/src/main.rs - CHANGED
mod routes;

use axum::{Router, routing::get};
use routes::{AppState, SharedStore};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tq_core::config::Config;
use tq_core::persistence;
use tq_core::store::TaskStore;

async fn health() -> String {
    "tq ok".to_string()
}

#[tokio::main]
async fn main() {
    let config = Config::load(Path::new("config.toml"), None).unwrap_or_default();
    let data_path = config.data_dir.join("tasks.json");
    let tasks = persistence::load(&data_path).unwrap_or_default();
    let store: SharedStore = Arc::new(Mutex::new(TaskStore::new(tasks)));
    let state = AppState { store, data_path }; // two values, one state
    let app = Router::new()
        .route("/", get(health))
        .merge(routes::router(state));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The Result

$ rm -f tasks.json
$ make serve

In another terminal - add tasks through the API, then stop the server and start it again:

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"buy milk"}'
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"send report"}'
{"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T10:05:00Z"}

Stop the server (Ctrl+C), start it again (make serve):

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"},
 {"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T10:05:00Z"}]

Both entries now leave a mark. CLI and API write to one ledger.

The complete tq code for this chapter is in 8-ready/02-one-state-two-values/.