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
tqcode for this chapter is in8-ready/02-one-state-two-values/.