Berbagi data antar handler dengan AppState dan with_state: connection pool, config, dan nilai mutable lewat Arc; memahami cara kerja typed state access, multiple state, dan mengapa ini fondasi arsitektur aplikasi Axum.

Sejauh ini handler kita semuanya stateless — tiap request dijawab tanpa perlu menyimpan apa pun. Padahal aplikasi nyata harus berbagi data: database pool, konfigurasi, counter, dan koneksi layanan eksternal. Episode ini membahas mekanisme Axum untuk itu: AppState.
Mengapa state perlu dipahami dengan benar? Karena kesalahan umum di sini — memegang Mutex di tempat yang salah, membungkus pool yang sudah thread-safe dengan Arc berlapis, atau menyimpan tipe yang tidak Clone — akan menimbulkan error Send/Sync yang membingungkan. Di episode ini kita bedah konsepnya dulu, lalu praktikkan dengan pola yang benar dan alasan di baliknya.
AppState adalah nilai yang didaftarkan ke Router saat aplikasi dibangun, lalu diakses handler lewat extractor State. Alurnya:
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db_url: String,
port: u16,
}
async fn info(State(state): State<AppState>) -> String {
format!("DB: {}, port: {}", state.db_url, state.port)
}
#[tokio::main]
async fn main() {
let state = AppState {
db_url: "postgres://localhost/mydb".into(),
port: 5432,
};
let app = Router::new()
.route("/info", get(info))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Tiga hal penting:
#[derive(Clone)] — state dibagikan ke seluruh handler, dan tiap handler menerima clone-nya. Persyaratannya state harus Clone + Send + Sync..with_state(state) — momen "injeksi": state disuntikkan ke router beserta semua handler di dalamnya.State<AppState> — extractor yang mengambil clone untuk handler saat ini.Konsekuensi desain: karena tiap handler mendapat clone, tiap handler bebas memegangnya selama request berlangsung tanpa bersaing dengan handler lain. Kompiler Axum bahkan memastikan kalian tidak bisa lupa memanggil .with_state — jika ada handler yang butuh State tetapi router tidak menyediakannya, program gagal start dengan pesan jelas.
Men-clone AppState per request itu murah jika isinya String kecil. Tapi untuk connection pool atau cache besar, clone per request mahal. Solusinya: simpan Arc<T> di dalam state — Arc adalah pointer yang murah untuk di-clone (hanya menambah refcount).
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: Arc<MyPool>,
config: Arc<Config>,
}
struct MyPool {
// misal: koneksi DB
}
#[derive(Clone)]
struct Config {
max_connections: u32,
}
#[derive(Clone)]
struct AppStateWithPool {
db: Arc<MyPool>,
}Perhatikan: Arc memindahkan "siapa yang punya" data dari state ke heap bersama, sehingga Clone state hanya menyalin pointer. Ini pola standar untuk sqlx::PgPool — yang memang sudah Clone dan di dalamnya sudah memakai Arc sendiri — jadi untuk sqlx kalian tidak perlu membungkus Arc lagi.
Warning
Jangan menulis Arc<Arc<T>> atau Arc<Mutex<T>> untuk data yang sebenarnya sudah thread-safe dan murah di-clone — misal sqlx::PgPool, yang sudah aman dibagikan antar task. Lapisan Arc ganda hanya menambah kebisingan. Aturan praktis: pakai Arc hanya untuk tipe yang belum cheap-clone (cache besar, struct berat).
State harus Sync — artinya bisa dipakai dari banyak thread bersamaan. Untuk data mutable, gunakan primitif yang tepat:
| Kebutuhan | Alat | Contoh |
|---|---|---|
| Counter / flag angka | AtomicU64, AtomicBool | hitungan request |
| Data mutable kompleks | tokio::sync::Mutex atau std::sync::Mutex | cache dalam memori |
| Satu penulis banyak pembaca | tokio::sync::RwLock | config yang diperbarui berkala |
Contoh counter request dengan atomic:
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
#[derive(Clone)]
struct AppState {
request_count: Arc<AtomicU64>,
}
async fn stats(State(state): State<AppState>) -> String {
state.request_count.fetch_add(1, Ordering::Relaxed);
format!("request ke-{}", state.request_count.load(Ordering::Relaxed))
}Perbedaan penting tokio::sync::Mutex vs std::sync::Mutex: gunakan tokio version saat lock dipegang melintasi .await (karena std version memblokir thread). Jika lock hanya dipakai untuk blok pendek tanpa await — seperti update counter — std version lebih murah.
Ini pola yang akan dipakai terus-menerus sejak episode 9:
use sqlx::postgres::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
async fn health(State(state): State<AppState>) -> String {
if sqlx::query("SELECT 1").execute(&state.db).await.is_ok() {
"db: ok".into()
} else {
"db: down".into()
}
}PgPool sudah Clone (di dalamnya Arc ke pool nyata), jadi tidak perlu Arc tambahan. Setiap handler memegang satu referensi pool; koneksi dipinjam dari pool per query dan dikembalikan otomatis. Kita membangun CRUD penuh di atas pola ini di episode 9.
Boleh punya lebih dari satu tipe state dalam satu router, selama tidak ada duplikasi tipe:
#[derive(Clone)]
struct DbState {
db: PgPool,
}
#[derive(Clone)]
struct ConfigState {
feature_flags: Arc<std::collections::HashMap<String, bool>>,
}
async fn flags(
State(cfg): State<ConfigState>,
State(db): State<DbState>,
) -> String {
let flags = cfg.feature_flags;
format!("db aktif, {} flag terdaftar", flags.len())
}
fn app() -> Router {
Router::new()
.route("/flags", axum::routing::get(flags))
.with_state(DbState { db: PgPool::new_lazy("postgres://x") })
.with_state(ConfigState { feature_flags: Arc::new(Default::default()) })
}Bisa, tapi sebaiknya tidak dilakukan berlebihan. Satu AppState besar berisi beberapa field lebih mudah ditelusuri daripada banyak state kecil yang harus di-inject satu per satu. Gunakan multiple state hanya jika dua domain benar-benar terpisah (misal koneksi DB vs config runtime).
Note
Aturan praktis: satu struct AppState (atau beberapa field di dalamnya) untuk hal-hal yang saling terkait, dan Arc untuk data berat yang dibagikan. Multiple state adalah fitur untuk kasus khusus, bukan gaya default.
State bisa dipecah per sub-router — berguna saat modul punya kebutuhan berbeda:
fn admin_router() -> Router<AdminState> {
Router::new().route("/admin/panel", axum::routing::get(panel))
}
fn app() -> Router {
Router::new()
.route("/public", axum::routing::get(|| async { "publik" }))
.merge(admin_router().with_state(AdminState { /* ... */ }))
}Perhatikan tipe Router<AdminState> — Axum 0.8 memakai generic state di tipe Router. Router publik yang tanpa state adalah Router<()> (unit). Begitu router di-with_state, tipe statenya berubah dan hanya handler yang butuh state itu yang bisa dipasang.
Pada episode 7 ini kalian telah menguasai state management:
AppState + .with_state() adalah mekanisme berbagi data antar handler.Clone + Send + Sync; setiap handler menerima clone.Arc untuk data berat yang cheap-clone; Mutex/Atomic untuk data mutable.PgPool sudah thread-safe dan murah di-clone — jangan dibungkus Arc lagi.Di episode 8 selanjutnya kita susun middleware stack — TraceLayer, TimeoutLayer, CorsLayer, dan from_fn_with_state — supaya logging, CORS, dan time limit berjalan di atas semua handler sekaligus. Sampai jumpa di episode 8!