Shared State
So far every handler you've written has been a closed little world. It takes a request apart with extractors, builds a response, and forgets everything. That's fine for an echo endpoint, but a real books API needs to remember things: the books themselves, a database connection, maybe a config loaded at boot. Where does that live?
The instinct from other languages is a global — a static variable, a singleton,
a module-level dict the handlers all reach into. In Rust that fights you hard:
globals have to be 'static, thread-safe, and usually unsafe or wrapped in a
macro to even compile. axum's answer is cleaner and it's the whole point of this
phase.
📝 Mental model: handlers stay stateless. Anything shared lives in a state value that you hand to the router with
.with_state(state). Each handler that needs it asks for it back through theStateextractor — the same "argument extracts from the request" idea you already know, except this argument extracts from the application, not the HTTP request. No globals, nounsafe, fully type-checked.
Defining and attaching state
State is just a type you define. Wire it up in three moves: declare the type,
attach an instance with .with_state(...), and pull it into handlers with the
State extractor.
use HashMap;
use ;
use ;
async
let state = AppState ;
let app = new.route.with_state;
What just happened: AppState is a plain struct holding our in-memory book
store. We built one instance, handed it to the router with .with_state(state),
and the list handler asked for it back by taking State(state): State<AppState>
as an argument. Inside, state.books.lock() gives temporary exclusive access to
the HashMap, and we clone the values into a Vec to return as JSON. Notice the
handler never touched a global — the state came in the front door like any other
extractor.
The State(state) in the argument list is a destructuring pattern: State is a
wrapper tuple struct, and State(state) unwraps it so state is your bare
AppState. You'll see the same shape for Path(id) and Json(payload) — it's a
consistent axum idiom, not special syntax for state.
Writing works the same way. Here's the insert side of the books API:
use StatusCode;
async
let app = new
.route
.with_state;
What just happened: create takes two extractors. Order matters here:
State (and any other non-body extractor) comes first, and the body-consuming
Json comes last — axum only lets one extractor consume the request body,
and it has to be the final argument. We lock() the store, this time binding it
mut so we can insert, and return 201 Created. Both handlers share the same
store because they share the same state.
Why Arc<Mutex<...>> and not just a HashMap
Here's the insight that trips everyone up the first time. Why isn't AppState
this?
⚠️ The state type must be Clone, because axum clones it once per request
before handing it to your handler. If books were a bare HashMap, each request
would get its own copy of the map. Insert a book in one request, and the next
request — working off a fresh clone of the original — wouldn't see it. You'd have
a store that silently forgets everything. It compiles; it just doesn't work.
Arc<Mutex<HashMap<...>>> fixes this by separating the two things Clone could
mean:
Arc(atomically reference-counted pointer) makes cloning cheap and shared. Cloning anArcdoesn't copy theHashMap— it bumps a reference count and hands back another pointer to the same map. Every request's clone ofAppStatepoints at one underlying store.Mutexmakes that shared access safe. Many requests run concurrently on different threads; without a lock, two of them writing to the sameHashMapat once is a data race..lock()grants one-at-a-time access and blocks the rest until the guard is dropped.
// Cloning AppState clones the Arc, NOT the HashMap behind it.
let a = state.clone;
let b = state.clone;
// a.books and b.books are two Arc handles to ONE shared Mutex<HashMap<...>>.
What just happened: every clone is just another pointer to the same data. That
is exactly what you want — shared, not copied. Read it for write access with
RwLock instead of Mutex (Arc<RwLock<...>>) when reads vastly outnumber
writes, since RwLock lets many readers in at once.
💡 You won't usually do this dance for a real database. A
sqlx::PgPoolis alreadyCloneand internally anArc, so you store it directly — noMutexneeded:struct AppState { db: PgPool }. The pool manages its own connections and concurrency. TheArc<Mutex<...>>pattern is for plain in-memory data you own, like ourHashMaptoy store.
State vs Extension
There's an older way to share data: Extension. You attach a value as a layer
with .layer(Extension(value)) and pull it out with the Extension extractor.
// The older Extension approach — works, but prefer State.
let app = new
.route
.layer;
async
What just happened: functionally this does the same job. The crucial
difference is when mistakes are caught. With State, the router won't
compile unless its state type matches what every handler asks for — wire up the
wrong type and the build fails at your desk. Extension is looked up by type at
runtime: forget to add the layer, or ask for the wrong type, and the request
panics with 500 only when that endpoint is actually hit.
💡 Prefer
State. It's type-checked at compile time, reads cleaner, and is the modern axum default. Reach forExtensiononly when you genuinely can't know the state type at the router's construction site — for instance, when middleware injects per-request values further down the stack. For app-wide dependencies like our store or a DB pool,Stateis the right tool.
Recap
- Handlers are stateless; shared dependencies live in a state value
attached to the router with
.with_state(state)and extracted viaState<T>. - The state type must be
Clone— axum clones it per request. Mutable data must sit behind a shared pointer, or each request mutates a throwaway copy. Arcmakes the clone share rather than copy;Mutex(orRwLock) makes that shared access thread-safe.- A real
sqlx::PgPoolis alreadyClone/Arc, so you store it directly — noMutexwrapper. - Body-consuming extractors like
Jsongo last in a handler's argument list;Stateand other non-body extractors come first. - Prefer
State(compile-time checked) overExtension(runtime lookup that panics if missing).
Quick check
Lock these in before moving on to middleware.
[
{
"q": "Why must your AppState type derive Clone?",
"choices": [
"So you can compare two states for equality",
"Because axum clones the state once per request before handing it to the handler",
"So Rust can store it in a global static",
"It doesn't have to — Clone is optional on state"
],
"answer": 1,
"explain": "axum clones the state value for each request. That's why mutable data must be behind an Arc so the clone shares one store instead of copying it."
},
{
"q": "What does cloning an Arc<Mutex<HashMap<...>>> actually copy?",
"choices": [
"The whole HashMap, deeply",
"Nothing — it just bumps a reference count and returns another pointer to the same data",
"Only the Mutex, but not the map inside it",
"A snapshot of the map at clone time"
],
"answer": 1,
"explain": "Arg::clone increments a reference count and returns another handle to the same underlying Mutex<HashMap>. All clones see one shared store — exactly what shared state needs."
},
{
"q": "Why prefer State over Extension for app-wide dependencies?",
"choices": [
"Extension is faster at runtime",
"State is checked at compile time, while a missing or mistyped Extension panics at runtime when the endpoint is hit",
"Extension can't hold a database pool",
"State allows globals and Extension does not"
],
"answer": 1,
"explain": "State ties the router's state type to what handlers request, so mismatches fail to compile. Extension is resolved by type at runtime and panics with a 500 only when that route runs."
}
]
← Phase 3: Handlers & IntoResponse · Guide overview · Phase 5: Middleware with Tower →
Check your understanding
1. Why must your AppState type derive Clone?
2. What does cloning an Arc<Mutex<HashMap<...>>> actually copy?
3. Why prefer State over Extension for app-wide dependencies?