Guide contents

Lesson 58 of 94

Cell & RefCell: Interior Mutability

The borrow rules say: many &T or one &mut T. Occasionally you need to change something that is only reachable through a shared reference, for example a counter inside an object that many parts of the program read, or shared state behind an Rc. Interior mutability types allow that, while keeping Rust's guarantees:

  • Cell<T>: replace or copy the value in and out. No references to the inside are ever handed out, so it's always safe. Best for small Copy values.
  • RefCell<T>: hands out Ref and RefMut guards, and enforces the borrow rules at runtime instead of compile time. Breaking them panics.

Both are single-threaded. Their thread-safe counterparts are atomics and Mutex/RwLock (Shared State).

Concept Rust
Changing a field through &self Only fields in a Cell/RefCell change through &self
Breaking the borrow rules at runtime RefCell panics "already borrowed" at runtime
Mutable value shared by several owners Rc<RefCell<T>>
Lazy field initialization OnceCell<T> / LazyCell<T>

1. The problem: changing state through &self

A method that only needs &self can't update a field, even something as harmless as a counter.

Rust✗ does not compile · E0594
struct Api {
    calls: u32,
}

impl Api {
    fn get(&self, path: &str) -> String {
        self.calls += 1;
        format!("GET {path}")
    }
}

fn main() {
    let api = Api { calls: 0 };
    println!("{}", api.get("/health"));
}
Output
error[E0594]: cannot assign to `self.calls`, which is behind a `&` reference
 --> src/main.rs:7:9
  |
7 |         self.calls += 1;
  |         ^^^^^^^^^^^^^^^ `self` is a `&` reference, so it cannot be written to
  |
help: consider changing this to be a mutable reference
  |
6 |     fn get(&mut self, path: &str) -> String {
  |             +++

Changing the method to &mut self would force every caller to have exclusive access, which may not be possible if the Api is shared.

2. Cell<T>: a mutable slot for Copy values

Cell::get copies the value out and Cell::set replaces it. Because no reference to the inside ever exists, this can't break any borrow rule.

Rust
use std::cell::Cell;

struct Api {
    calls: Cell<u32>,
}

impl Api {
    fn get(&self, path: &str) -> String {
        self.calls.set(self.calls.get() + 1);
        format!("GET {path}")
    }
}

fn main() {
    let api = Api { calls: Cell::new(0) };
    let shared_a = &api;
    let shared_b = &api;
    println!("{}", shared_a.get("/health"));
    println!("{}", shared_b.get("/users"));
    println!("calls: {}", api.calls.get());
}
Output
GET /health
GET /users
calls: 2

3. More Cell operations

Rust
use std::cell::Cell;

fn main() {
    let flag = Cell::new(false);
    let old = flag.replace(true);
    println!("was {old}, now {}", flag.get());

    let counter = Cell::new(10);
    counter.update(|n| n * 2);
    println!("{}", counter.get());

    let name = Cell::new(String::from("draft"));
    let taken = name.take(); // leaves String::default() behind
    println!("took {taken:?}, left {:?}", name.into_inner());
}
Output
was false, now true
20
took "draft", left ""

Cell<T> works with non-Copy types too, through replace, take, and into_inner; only get needs Copy.

4. RefCell<T>: borrowing checked at runtime

borrow() returns a guard that acts like &T; borrow_mut() returns one that acts like &mut T. The guards end the borrow when they're dropped.

Rust
use std::cell::RefCell;

fn main() {
    let log = RefCell::new(Vec::new());
    log.borrow_mut().push("started");
    log.borrow_mut().push("running");
    println!("{} entries: {:?}", log.borrow().len(), log.borrow());
}
Output
2 entries: ["started", "running"]

5. Breaking the rules panics

Holding a borrow() while calling borrow_mut() is exactly the aliasing the compiler normally prevents. With RefCell, it's caught at runtime instead, with a panic.

Rustpanics at runtime
use std::cell::RefCell;

fn main() {
    let queue = RefCell::new(vec![1, 2, 3]);
    let first = queue.borrow(); // shared borrow held...
    queue.borrow_mut().push(4); // ...while asking for an exclusive one
    println!("{:?}", first);
}
Output
thread 'main' panicked at src/main.rs:6:11:
RefCell already borrowed

This is RefCell's trade-off: flexibility in exchange for a runtime check that can fail. Keep borrows short to make conflicts impossible.

6. Keeping borrows short

Most RefCell panics come from a guard living longer than intended. Scope guards tightly, or copy out what you need.

Rust
use std::cell::RefCell;

fn main() {
    let queue = RefCell::new(vec![1, 2, 3]);
    let first = *queue.borrow().first().unwrap(); // guard dropped at end of statement
    queue.borrow_mut().push(first * 10);
    {
        let q = queue.borrow();
        println!("{:?} (len {})", *q, q.len());
    } // guard dropped here
    queue.borrow_mut().clear();
    println!("{:?}", queue.borrow());
}
Output
[1, 2, 3, 10] (len 4)
[]

7. try_borrow_mut: checking instead of panicking

When a conflict is possible and you want to handle it, try_borrow and try_borrow_mut return a Result.

Rust
use std::cell::RefCell;

fn main() {
    let cache = RefCell::new(vec![String::from("warm")]);
    let reader = cache.borrow();
    match cache.try_borrow_mut() {
        Ok(mut c) => c.push(String::from("more")),
        Err(_) => println!("cache is being read; skipping update"),
    }
    drop(reader);
    if let Ok(mut c) = cache.try_borrow_mut() {
        c.push(String::from("more"));
    }
    println!("{:?}", cache.borrow());
}
Output
cache is being read; skipping update
["warm", "more"]

8. Rc<RefCell<T>>: shared *and* mutable

Rc gives several owners; RefCell lets them change the value. Together they model an object reachable from several places, mutable through any of them.

Rust
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Account {
    balance: i64,
}

fn main() {
    let account = Rc::new(RefCell::new(Account { balance: 100 }));
    let mobile_app = Rc::clone(&account);
    let web_app = Rc::clone(&account);

    mobile_app.borrow_mut().balance -= 30;
    web_app.borrow_mut().balance += 50;
    println!("{:?}", account.borrow());
}
Output
Account { balance: 120 }

Use this combination sparingly. It trades compile-time checking for runtime checks, and it tends to spread through a codebase. Often a clear owner that passes &mut down is simpler.

9. A shared counter updated by closures

Closures that each hold an Rc<Cell<_>> can update one shared counter, which is handy for callbacks.

Rust
use std::cell::Cell;
use std::rc::Rc;

fn main() {
    let clicks = Rc::new(Cell::new(0));
    let handlers: Vec<Box<dyn Fn()>> = (0..3)
        .map(|_| {
            let c = Rc::clone(&clicks);
            Box::new(move || c.set(c.get() + 1)) as Box<dyn Fn()>
        })
        .collect();
    for h in &handlers {
        h();
        h();
    }
    println!("total clicks: {}", clicks.get());
}
Output
total clicks: 6

The closures are Fn (not FnMut) because Cell::set only needs &self.

10. Caching inside a &self method

A computed value can be cached lazily in a RefCell<Option<T>>, keeping the method signature &self.

Rust
use std::cell::RefCell;

struct Report {
    rows: Vec<u64>,
    cached_total: RefCell<Option<u64>>,
}

impl Report {
    fn total(&self) -> u64 {
        if let Some(t) = *self.cached_total.borrow() {
            return t;
        }
        println!("  computing total...");
        let t = self.rows.iter().sum();
        *self.cached_total.borrow_mut() = Some(t);
        t
    }
}

fn main() {
    let r = Report { rows: vec![120, 340, 90], cached_total: RefCell::new(None) };
    println!("{}", r.total());
    println!("{}", r.total());
}
Output
  computing total...
550
550

11. OnceCell: set exactly once

std::cell::OnceCell holds a value that is set once and then only read. get_or_init initializes it on first use. It's simpler than RefCell<Option<T>> for lazy fields.

Rust
use std::cell::OnceCell;

struct Page {
    markdown: String,
    html: OnceCell<String>,
}

impl Page {
    fn html(&self) -> &str {
        self.html.get_or_init(|| {
            println!("  rendering...");
            format!("<p>{}</p>", self.markdown)
        })
    }
}

fn main() {
    let page = Page { markdown: String::from("Hello, Rust"), html: OnceCell::new() };
    println!("{}", page.html());
    println!("{}", page.html());
}
Output
  rendering...
<p>Hello, Rust</p>
<p>Hello, Rust</p>

OnceCell::get_or_init returns a plain &T tied to the cell, so there are no guards to manage.

12. LazyCell: initialization defined up front

LazyCell bundles the initializer with the cell, and runs it on first access.

Rust
use std::cell::LazyCell;

fn main() {
    let expensive_table = LazyCell::new(|| {
        println!("  building lookup table");
        (0..10).map(|i| i * i).collect::<Vec<u32>>()
    });
    println!("before first use");
    println!("{}", expensive_table[3]);
    println!("{}", expensive_table[9]);
}
Output
before first use
  building lookup table
9
81

For process-wide lazy globals shared across threads, the equivalents are std::sync::OnceLock and LazyLock (Atomics, Send & Sync).

13. A test double that records calls

A trait method takes &self, but a mock implementation wants to record every call. RefCell lets the mock remember calls without changing the trait. This is one of the most common real uses of RefCell.

Rust
use std::cell::RefCell;

trait Mailer {
    fn send(&self, to: &str, subject: &str);
}

struct FakeMailer {
    sent: RefCell<Vec<(String, String)>>,
}

impl Mailer for FakeMailer {
    fn send(&self, to: &str, subject: &str) {
        self.sent.borrow_mut().push((to.to_string(), subject.to_string()));
    }
}

fn notify_overdue(mailer: &dyn Mailer, invoices: &[(&str, u32)]) {
    for (customer, days) in invoices {
        if *days > 30 {
            mailer.send(customer, &format!("Invoice {days} days overdue"));
        }
    }
}

fn main() {
    let fake = FakeMailer { sent: RefCell::new(Vec::new()) };
    notify_overdue(&fake, &[("a@x.com", 10), ("b@x.com", 45), ("c@x.com", 31)]);
    let sent = fake.sent.borrow();
    println!("{} mails: {:?}", sent.len(), *sent);
    assert_eq!(sent[0].0, "b@x.com");
    println!("assertions passed");
}
Output
2 mails: [("b@x.com", "Invoice 45 days overdue"), ("c@x.com", "Invoice 31 days overdue")]
assertions passed

14. A shared, mutable graph

Nodes that point to each other and must be updated after creation are typically Rc<RefCell<Node>>.

Rust
use std::cell::RefCell;
use std::rc::Rc;

struct User {
    name: &'static str,
    following: Vec<Rc<RefCell<User>>>,
    followers: u32,
}

fn follow(from: &Rc<RefCell<User>>, to: &Rc<RefCell<User>>) {
    from.borrow_mut().following.push(Rc::clone(to));
    to.borrow_mut().followers += 1;
}

fn main() {
    let ana = Rc::new(RefCell::new(User { name: "ana", following: vec![], followers: 0 }));
    let ben = Rc::new(RefCell::new(User { name: "ben", following: vec![], followers: 0 }));
    let cy = Rc::new(RefCell::new(User { name: "cy", following: vec![], followers: 0 }));
    follow(&ana, &ben);
    follow(&cy, &ben);
    follow(&ben, &cy);

    for u in [&ana, &ben, &cy] {
        let u = u.borrow();
        let names: Vec<&str> = u.following.iter().map(|f| f.borrow().name).collect();
        println!("{} follows {:?}, has {} follower(s)", u.name, names, u.followers);
    }
}
Output
ana follows ["ben"], has 0 follower(s)
ben follows ["cy"], has 2 follower(s)
cy follows ["ben"], has 1 follower(s)

Mutual follows like ben ↔ cy form Rc cycles and leak when dropped. In a long-running program you'd use Weak for one direction, or store users in a Vec and refer to them by index, which is often the simplest design of all.

15. The index-based alternative

Storing nodes in a Vec and using indices instead of pointers avoids both Rc and RefCell. Mutation is ordinary &mut access. Many Rust graph libraries work this way.

Rust
struct User {
    name: &'static str,
    following: Vec<usize>,
    followers: u32,
}

struct Network {
    users: Vec<User>,
}

impl Network {
    fn add(&mut self, name: &'static str) -> usize {
        self.users.push(User { name, following: vec![], followers: 0 });
        self.users.len() - 1
    }

    fn follow(&mut self, from: usize, to: usize) {
        self.users[from].following.push(to);
        self.users[to].followers += 1;
    }
}

fn main() {
    let mut net = Network { users: Vec::new() };
    let (ana, ben, cy) = (net.add("ana"), net.add("ben"), net.add("cy"));
    net.follow(ana, ben);
    net.follow(cy, ben);
    net.follow(ben, cy);
    for u in &net.users {
        let names: Vec<&str> = u.following.iter().map(|&i| net.users[i].name).collect();
        println!("{} follows {:?}, {} follower(s)", u.name, names, u.followers);
    }
}
Output
ana follows ["ben"], 0 follower(s)
ben follows ["cy"], 2 follower(s)
cy follows ["ben"], 1 follower(s)

16. RefCell borrows are released at the end of the statement

A temporary guard (one not stored in a variable) is dropped at the end of its statement, so these calls never conflict.

Rust
use std::cell::RefCell;

fn main() {
    let items = RefCell::new(vec![3, 1, 2]);
    items.borrow_mut().sort();
    let len = items.borrow().len();
    items.borrow_mut().push(len as i32);
    println!("{:?}", items.borrow());
}
Output
[1, 2, 3, 3]

17. A classic RefCell trap: calling back into yourself

A method holding borrow_mut() that calls code which borrows the same cell again panics. Collect what you need, release the borrow, then call out.

Rustpanics at runtime
use std::cell::RefCell;

struct Store {
    items: RefCell<Vec<String>>,
}

impl Store {
    fn count(&self) -> usize {
        self.items.borrow().len()
    }

    fn add_and_report(&self, item: &str) {
        let mut items = self.items.borrow_mut();
        items.push(item.to_string());
        println!("now {} items", self.count()); // borrow() while borrow_mut() is alive
    }
}

fn main() {
    let s = Store { items: RefCell::new(vec![]) };
    s.add_and_report("a");
}
Output
thread 'main' panicked at src/main.rs:9:20:
RefCell already mutably borrowed

The fix: end the mutable borrow before calling self.count(), for example by putting the push in its own statement: self.items.borrow_mut().push(...).

18. Cell for flags in a non-mutable struct

Rust
use std::cell::Cell;

struct Connection {
    host: &'static str,
    used: Cell<bool>,
    bytes_sent: Cell<u64>,
}

impl Connection {
    fn send(&self, payload: &[u8]) {
        self.used.set(true);
        self.bytes_sent.set(self.bytes_sent.get() + payload.len() as u64);
    }
}

fn main() {
    let pool = [
        Connection { host: "db-1", used: Cell::new(false), bytes_sent: Cell::new(0) },
        Connection { host: "db-2", used: Cell::new(false), bytes_sent: Cell::new(0) },
    ];
    pool[0].send(b"SELECT 1");
    pool[0].send(b"SELECT 2");
    for c in &pool {
        println!("{}: used={} bytes={}", c.host, c.used.get(), c.bytes_sent.get());
    }
}
Output
db-1: used=true bytes=16
db-2: used=false bytes=0

pool isn't even declared mut, yet its connections track usage.

19. RefCell::replace and take

Rust
use std::cell::RefCell;

fn main() {
    let batch = RefCell::new(vec!["e1", "e2", "e3"]);
    let flushed = batch.take(); // leaves an empty Vec behind
    println!("flushed {:?}, batch now {:?}", flushed, batch.borrow());
    let old = batch.replace(vec!["e4"]);
    println!("old {:?}, new {:?}", old, batch.borrow());
}
Output
flushed ["e1", "e2", "e3"], batch now []
old [], new ["e4"]

20. Realistic example: a shopping cart shared by two views

Two UI components hold the same cart. One adds items, the other reads totals; both see every change.

Rust
use std::cell::RefCell;
use std::rc::Rc;

struct Cart {
    items: Vec<(&'static str, u32)>,
}

struct ProductPage {
    cart: Rc<RefCell<Cart>>,
}

struct HeaderBadge {
    cart: Rc<RefCell<Cart>>,
}

impl ProductPage {
    fn add_to_cart(&self, name: &'static str, cents: u32) {
        self.cart.borrow_mut().items.push((name, cents));
    }
}

impl HeaderBadge {
    fn render(&self) -> String {
        let cart = self.cart.borrow();
        let total: u32 = cart.items.iter().map(|(_, c)| c).sum();
        format!("[cart: {} items, {:.2}]", cart.items.len(), total as f64 / 100.0)
    }
}

fn main() {
    let cart = Rc::new(RefCell::new(Cart { items: Vec::new() }));
    let page = ProductPage { cart: Rc::clone(&cart) };
    let badge = HeaderBadge { cart: Rc::clone(&cart) };
    println!("{}", badge.render());
    page.add_to_cart("keyboard", 12_900);
    page.add_to_cart("mouse", 3_900);
    println!("{}", badge.render());
}
Output
[cart: 0 items, 0.00]
[cart: 2 items, 168.00]

21. Realistic example: an in-memory event store with subscribers

Rust
use std::cell::RefCell;
use std::rc::Rc;

type Handler = Box<dyn Fn(&str)>;

struct EventStore {
    events: RefCell<Vec<String>>,
    subscribers: RefCell<Vec<Handler>>,
}

impl EventStore {
    fn subscribe(&self, h: Handler) {
        self.subscribers.borrow_mut().push(h);
    }

    fn append(&self, event: &str) {
        self.events.borrow_mut().push(event.to_string()); // borrow ends here
        for s in self.subscribers.borrow().iter() {
            s(event);
        }
    }
}

fn main() {
    let store = EventStore { events: RefCell::new(vec![]), subscribers: RefCell::new(vec![]) };
    let audit = Rc::new(RefCell::new(Vec::new()));
    let audit_handle = Rc::clone(&audit);
    store.subscribe(Box::new(move |e| audit_handle.borrow_mut().push(format!("audit: {e}"))));
    store.subscribe(Box::new(|e| {
        if e.starts_with("payment") {
            println!("notify finance: {e}");
        }
    }));
    store.append("user_signed_up");
    store.append("payment_received");
    println!("{} events, audit {:?}", store.events.borrow().len(), audit.borrow());
}
Output
notify finance: payment_received
2 events, audit ["audit: user_signed_up", "audit: payment_received"]

22. Realistic example: memoized recursive function

A RefCell<HashMap> cache lets a recursive function that takes &self memoize its results.

Rust
use std::cell::RefCell;
use std::collections::HashMap;

struct PathCounter {
    blocked: Vec<(usize, usize)>,
    memo: RefCell<HashMap<(usize, usize), u64>>,
}

impl PathCounter {
    // number of right/down paths from (r, c) to (0, 0)
    fn paths(&self, r: usize, c: usize) -> u64 {
        if self.blocked.contains(&(r, c)) {
            return 0;
        }
        if r == 0 || c == 0 {
            return 1;
        }
        if let Some(&v) = self.memo.borrow().get(&(r, c)) {
            return v;
        }
        let v = self.paths(r - 1, c) + self.paths(r, c - 1);
        self.memo.borrow_mut().insert((r, c), v);
        v
    }
}

fn main() {
    let grid = PathCounter { blocked: vec![(1, 1)], memo: RefCell::new(HashMap::new()) };
    println!("{}", grid.paths(3, 3));
    let big = PathCounter { blocked: vec![], memo: RefCell::new(HashMap::new()) };
    println!("{}", big.paths(16, 16));
    println!("memo entries: {}", big.memo.borrow().len());
}
Output
8
601080390
memo entries: 256

Notice the borrow() in the if let is released before the recursive calls, and borrow_mut() happens only after them, so borrows never overlap.

23. Realistic example: rate-limit bookkeeping behind &self

A client object shared across a program tracks how many requests it has made in the current window, without requiring &mut everywhere.

Rust
use std::cell::Cell;

struct RateLimitedClient {
    limit: u32,
    window_start: Cell<u64>,
    used: Cell<u32>,
}

impl RateLimitedClient {
    fn call(&self, now_secs: u64, endpoint: &str) -> Result<String, String> {
        if now_secs - self.window_start.get() >= 60 {
            self.window_start.set(now_secs);
            self.used.set(0);
        }
        if self.used.get() >= self.limit {
            return Err(format!("rate limited, retry after {}s", 60 - (now_secs - self.window_start.get())));
        }
        self.used.set(self.used.get() + 1);
        Ok(format!("200 {endpoint}"))
    }
}

fn main() {
    let client = RateLimitedClient { limit: 2, window_start: Cell::new(0), used: Cell::new(0) };
    for (t, ep) in [(1, "/a"), (5, "/b"), (10, "/c"), (61, "/d")] {
        println!("t={t:>2} {:?}", client.call(t, ep));
    }
}
Output
t= 1 Ok("200 /a")
t= 5 Ok("200 /b")
t=10 Err("rate limited, retry after 50s")
t=61 Ok("200 /d")

24. When *not* to use interior mutability

Reach for Cell/RefCell only when a shared reference is truly unavoidable (trait methods with &self, callbacks, shared graphs, caches). If you can restructure so that one owner passes &mut down, do that: the compiler then checks everything, and there are no runtime panics.

Rust
struct Inventory {
    stock: Vec<(&'static str, u32)>,
}

impl Inventory {
    fn reserve(&mut self, sku: &str, qty: u32) -> bool {
        match self.stock.iter_mut().find(|(s, _)| *s == sku) {
            Some((_, have)) if *have >= qty => {
                *have -= qty;
                true
            }
            _ => false,
        }
    }
}

fn checkout(inv: &mut Inventory, cart: &[(&str, u32)]) -> Vec<bool> {
    cart.iter().map(|(sku, q)| inv.reserve(sku, *q)).collect()
}

fn main() {
    let mut inv = Inventory { stock: vec![("pen", 5), ("pad", 1)] };
    println!("{:?}", checkout(&mut inv, &[("pen", 2), ("pad", 2), ("pen", 3)]));
    println!("{:?}", inv.stock);
}
Output
[true, false, true]
[("pen", 0), ("pad", 1)]

Summary

  • Interior mutability lets you change data through &T. It moves some borrow checking from compile time to runtime.
  • Cell<T>: get/set/replace/take/update; never hands out references, never panics. Best for small Copy values like counters and flags.
  • RefCell<T>: borrow()/borrow_mut() guards, checked at runtime; conflicting borrows panic. Keep guards short; use try_borrow_mut when a conflict is possible.
  • Rc<RefCell<T>> is the single-threaded "shared, mutable object". Prefer indices or a single owner when you can.
  • OnceCell and LazyCell handle lazy, set-once values.
  • Multi-threaded code uses atomics, Mutex, RwLock, OnceLock, and LazyLock instead.

Next: Drop, Deref & Cow.