Introduction#
Usually, the borrow checker acts strictly: if a variable is declared without mut, the compiler will never allow modifying its fields through an immutable reference &T. This is standard Inherited Mutability.
However, in real-world code, situations arise where a component needs to store a cached response, record an error log, or update an internal identifier while holding only an immutable reference &self.
For these cases, the Rust standard library provides the Interior Mutability pattern. In this article, I explore how Cell and RefCell work, where they apply in practice, and how their runtime checks operate.
1. The Cell Container: Mutability Without References#
The simplest tool for interior mutability is std::cell::Cell<T>.
The key property of Cell<T> is that it never hands out direct references &T or &mut T to the value inside it.
Instead of references, Cell operates by reading, writing, or moving values as a whole:
get()— returns a copy of the inner value (requiresT: Copy).set(val)— completely replaces the inner value withval.replace(val)— storesvalinside the container and returns the previous value.take()— extracts the value, leavingDefault::default()in its place.
use std::cell::Cell;
struct Counter {
count: Cell<u32>,
}
fn main() {
let c = Counter { count: Cell::new(0) };
// The variable 'c' is NOT mutable! But we can freely modify count:
c.count.set(c.count.get() + 1);
println!("Current count: {}", c.count.get()); // Prints 1
}Note: For non-Copy types (like String or Vec), calling .get() will fail to compile. However, non-Copy types in Cell can still be updated via .set(), .replace(), and .take().
Because Cell<T> never issues references to its inner data, it does not need to track borrow counts. It has zero runtime overhead (Zero-cost abstraction) and zero risk of panicking! It is the ideal choice for flags, counters, and Copy types.
2. The RefCell Container: Dynamic Borrow Checking#
When storing a complex data structure (such as Vec<String>, HashMap, or a custom object) inside a container, copying it as a whole via Cell::get() is not an option.
For complex types, Rust provides std::cell::RefCell<T>.
RefCell<T> lets you request references to the inner value:
.borrow()— returns aRef<T>wrapper that acts like an immutable reference&T..borrow_mut()— returns aRefMut<T>wrapper that acts like a mutable reference&mut T..try_borrow()/.try_borrow_mut()— non-panicking variants returningResult, allowing safe checks when borrow conflicts might occur.
Runtime Borrow Checking#
The standard Rust compiler enforces the rule “either multiple & or exactly one &mut” at compile time.
RefCell defers this borrow counter check to runtime.
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
let mut b1 = data.borrow_mut();
b1.push(4);
// WARNING: b1 is still active! Attempting a second borrow_mut will PANIC:
// let mut b2 = data.borrow_mut(); // ALREADY BORROWED PANIC!
}Thanks to the RAII idiom, the Ref and RefMut smart pointers automatically decrement the borrow counter of RefCell in their Drop destructor when going out of scope. Therefore, calling drop(b1) explicitly allows the next data.borrow_mut() call to succeed smoothly.
Remember: RefCell does not bypass Rust borrow rules; it merely defers their enforcement to runtime. Architectural design flaws result in runtime panics rather than compile errors!
3. Real-World Use Cases#
Now that we understand how Cell and RefCell operate, let’s explore why changing data through an immutable reference &self is necessary when &mut self isn’t feasible.
In real-world code, using &mut self is not always an option. Here are three common scenarios:
1. Button Click Counters#
Consider a standard UI button component. Let’s try updating a click counter inside a &self method:
In UI frameworks, event handler methods usually accept &self because button instances are invoked from multiple parts of the application. Cell enables updating the internal click counter directly through &self without propagating &mut self across the entire call chain.
2. Log History Accumulation in Services#
Another scenario involves a shared application context App, accessed across components via &App to read settings. With RefCell, any function can record warnings into a shared log vector on the fly:
struct App {
name: String,
// Log history stored inside the application struct
logs: RefCell<Vec<String>>,
}
impl App {
fn log_error(&self, msg: &str) {
// Appends an entry to the vector holding only &self
self.logs.borrow_mut().push(msg.to_string());
}
}3. State Flags (“Already Initialized”)#
The same pattern applies to initialization flags in background audio players or loaders:
struct Player {
is_playing: Cell<bool>,
}
impl Player {
fn play(&self) {
if !self.is_playing.get() {
println!("Starting playback...");
self.is_playing.set(true);
}
}
}4. Multithreading Constraints#
An essential caveat: Cell and RefCell containers are designed strictly for single-threaded execution.
Neither type implements the Sync marker trait, meaning the compiler prevents passing references to them across thread boundaries at build time.
A detailed walkthrough of multithreaded interior mutability (Mutex, RwLock) and the Send / Sync traits will be covered in a separate dedicated article.
| Type | Issues References? | Borrow Checking | Type & Trait Requirements | Overhead |
|---|---|---|---|---|
Cell<T> | No (get/set only) | Not required | Requires T: Copy for .get() | Zero-cost |
RefCell<T> | Yes (Ref / RefMut) | Runtime (Panics on violation) | Works with any T | Small runtime counter |
Let’s explore how these types operate in practical interactive code slides:
Test Your Knowledge!#
Take a short interactive quiz on interior mutability:


