#151 May 20, 2026

151. Cell<T> — Interior Mutability Without the Borrow Checker Drama

You hand the same struct to two closures and both want to bump a counter. &mut fights you, RefCell introduces runtime borrow checks you don’t need — Cell<T> quietly mutates through a shared & reference, with zero overhead, as long as you only ever swap whole values in and out.

The pain: shared & and a counter

Two closures, one counter, one immutable reference:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct Counter {
    hits: u32,
}

let counter = Counter { hits: 0 };
let bump = || {
    // ERROR: cannot assign to `counter.hits`, which is behind a `&` reference
    // counter.hits += 1;
};
bump();

Fn closures only capture &, so a plain field is read-only. You could redesign for FnMut and a single owner, but the moment you have two observers or a callback registry, you need interior mutability.

The fix: Cell<T> mutates through &

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::cell::Cell;

struct Counter {
    hits: Cell<u32>,
}

let counter = Counter { hits: Cell::new(0) };

let bump = || counter.hits.set(counter.hits.get() + 1);
let read = || counter.hits.get();

bump();
bump();
bump();
assert_eq!(read(), 3);

Cell::new wraps the value; get returns a copy (so T: Copy for that method); set overwrites. Both take &self — no &mut anywhere — which is why this works inside Fn closures, inside Rc, inside any structure that hands out shared references.

The invariant: you can never borrow the inside

This is the single rule that makes Cell<T> sound without runtime checks: you cannot get a reference to the value inside, only copies and swaps. There is no cell.as_ref(), no cell.deref(). The compiler enforces this — there’s nothing to alias, so the optimizer is free to assume the inner value can’t change underneath an outstanding &T.

That’s also why Cell<T> is !Sync — fine for one thread, but two threads racing on set would tear the value. For threads, reach for Atomic* (scalars) or Mutex<T> (the rest).

replace and take work for non-Copy types too

get requires T: Copy, but Cell works with String, Vec, anything — you just have to move the value out instead of copying it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::cell::Cell;

let slot: Cell<String> = Cell::new(String::from("hello"));

// `replace` swaps in a new value, returns the old one.
let old = slot.replace(String::from("world"));
assert_eq!(old, "hello");

// `take` is `replace(Default::default())`.
let now: String = slot.take();
assert_eq!(now, "world");
assert_eq!(slot.into_inner(), ""); // default String

This is the trick people miss: Cell<Vec<T>> is perfectly usable for a shared, append-only-ish buffer — you just swap the whole Vec in and out.

When to pick Cell vs RefCell

Cell<T> if you only need to swap whole values: counters, flags, configuration knobs, small Copy state, or any field where replace/take is enough. Zero runtime overhead, no panic risk.

RefCell<T> (tomorrow afternoon’s bite) if you need to borrow the inside — call &mut self methods on a Vec in place, hand a &str slice to a caller, anything where copying or swapping the whole value would be wrong. You pay for runtime borrow tracking and risk a panic, but you get back the ability to use the value normally.

Default to Cell when it fits — it almost always does for simple shared-state tweaks, and the “no inner references” rule turns out to be exactly what you wanted anyway.

← Previous 150. Vec::spare_capacity_mut — Fill a Vec From a Callback Without Zeroing It First