RustRefCell<T> & Interior Mutability

RefCell<T> and Interior Mutability

Rust's borrow checker enforces its rules at compile time: you can have many immutable references or exactly one mutable reference, but never both at the same time.

Sometimes you have code where you know the borrow rules are satisfied, but the compiler cannot prove it statically. Interior mutability is a design pattern that moves borrow checking to runtime, letting you mutate data even through what looks like an immutable reference.

RefCell<T> is the standard runtime borrow checker in Rust's standard library.

How RefCell Works

RefCell<T> wraps a value and tracks borrows at runtime using an internal counter. It exposes two main methods:

  • borrow() — returns a Ref<T> (immutable access). Multiple immutable borrows can coexist.

  • borrow_mut() — returns a RefMut<T> (mutable access). Only one mutable borrow may exist at a time, and no immutable borrows may be active simultaneously.

If you violate the rules — for example calling borrow_mut() while an immutable borrow is active — RefCell panics at runtime rather than failing at compile time.

RUST
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    // Immutable borrow — works fine
    {
        let view = data.borrow();
        println!("data: {:?}", *view);
    } // view dropped here, borrow released

    // Mutable borrow — works because no other borrow is active
    {
        let mut view = data.borrow_mut();
        view.push(4);
    } // mutable borrow released

    println!("after push: {:?}", data.borrow());
}
data: [1, 2, 3]
after push: [1, 2, 3, 4]
Runtime Panic on Borrow Violation

Unlike compile-time borrow errors, a RefCell violation crashes the program at the point where the conflicting borrow is attempted.

RUST
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(42);

    let _borrow1 = data.borrow(); // immutable borrow — OK
    let _borrow2 = data.borrow(); // second immutable borrow — also OK

    // This panics: cannot borrow mutably while immutable borrows exist
    let _mut_borrow = data.borrow_mut(); // PANIC at runtime
}
Warning
A RefCell panic is a program bug, not a graceful error. Structure your code so borrows are short-lived (released before the next one begins). Use try_borrow() and try_borrow_mut() if you need to handle the conflict non-fatally.
try_borrow and try_borrow_mut

The non-panicking variants return a Result instead of panicking. Use them when the borrow pattern depends on runtime state and a crash would be unacceptable.

RUST
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(100);

    let borrow = data.borrow();

    // try_borrow_mut returns Err if a borrow rule would be violated
    match data.try_borrow_mut() {
        Ok(mut val) => *val += 1,
        Err(e) => println!("could not borrow mutably: {}", e),
    }

    drop(borrow); // release the immutable borrow

    // Now the mutable borrow succeeds
    match data.try_borrow_mut() {
        Ok(mut val) => {
            *val += 1;
            println!("new value: {}", *val);
        }
        Err(e) => println!("error: {}", e),
    }
}
could not borrow mutably: already borrowed: BorrowMutError
new value: 101
The Classic Pattern: Rc<RefCell<T>>

Rc<T> provides shared ownership but only immutable access. RefCell<T> provides interior mutability for a single owner.

Combining them — Rc<RefCell<T>> — gives you shared mutable data on a single thread. This is one of the most commonly used patterns in Rust for building graphs, trees, and other data structures where multiple nodes need to mutate shared state.

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

fn main() {
    let shared = Rc::new(RefCell::new(vec![]));

    // Clone the Rc — both handles point to the same RefCell<Vec>
    let clone1 = Rc::clone(&shared);
    let clone2 = Rc::clone(&shared);

    clone1.borrow_mut().push(1);
    clone2.borrow_mut().push(2);
    shared.borrow_mut().push(3);

    println!("{:?}", shared.borrow()); // [1, 2, 3]
    println!("owners: {}", Rc::strong_count(&shared)); // 3
}
[1, 2, 3]
owners: 3
Note
The borrow rules still apply — you just cannot hold a borrow() and a borrow_mut() at the same time. Structure your code so borrows are released (go out of scope or are explicitly dropped) before the next one is acquired.
Practical Example — Shared Mutable Counter

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

fn increment(counter: &Rc<RefCell<i32>>, amount: i32) {
    *counter.borrow_mut() += amount;
}

fn main() {
    let counter = Rc::new(RefCell::new(0));

    let c1 = Rc::clone(&counter);
    let c2 = Rc::clone(&counter);

    increment(&c1, 10);
    increment(&c2, 5);
    increment(&counter, 3);

    println!("final count: {}", counter.borrow()); // 18
}
final count: 18
Cell<T> — Simpler Interior Mutability for Copy Types

Cell<T> is a lighter-weight alternative to RefCell<T> for types that implement Copy (integers, booleans, chars, etc.). Instead of handing out borrow guards, it works by copying the value in and out:

  • cell.get() — returns a copy of the inner value.

  • cell.set(value) — replaces the inner value.

RUST
use std::cell::Cell;

struct Config {
    value: i32,
    access_count: Cell<u32>,
}

impl Config {
    fn new(value: i32) -> Self {
        Config { value, access_count: Cell::new(0) }
    }

    fn read(&self) -> i32 {
        // &self is immutable, but Cell lets us update the counter
        self.access_count.set(self.access_count.get() + 1);
        self.value
    }
}

fn main() {
    let cfg = Config::new(42);
    println!("read: {}", cfg.read());
    println!("read: {}", cfg.read());
    println!("read: {}", cfg.read());
    println!("access count: {}", cfg.access_count.get()); // 3
}
read: 42
read: 42
read: 42
access count: 3
Tip
Prefer Cell<T> over RefCell<T> for Copy types — it has no borrow tracking overhead and can never panic.
Using RefCell in Tests — Mocking

One of the most practical uses of RefCell is writing test mocks. A mock needs to record calls made to it, but it is typically accessed through a shared immutable reference (e.g. a trait object). RefCell lets the mock mutate its internal log even through that immutable interface.

RUST
use std::cell::RefCell;

trait Logger {
    fn log(&self, message: &str);
}

struct MockLogger {
    messages: RefCell<Vec<String>>,
}

impl MockLogger {
    fn new() -> Self {
        MockLogger { messages: RefCell::new(vec![]) }
    }

    fn logged_messages(&self) -> Vec<String> {
        self.messages.borrow().clone()
    }
}

impl Logger for MockLogger {
    fn log(&self, message: &str) {
        // &self is immutable, but RefCell lets us mutate the vec
        self.messages.borrow_mut().push(message.to_string());
    }
}

fn run_process(logger: &dyn Logger) {
    logger.log("process started");
    logger.log("process finished");
}

fn main() {
    let logger = MockLogger::new();
    run_process(&logger);

    let msgs = logger.logged_messages();
    println!("logged {} messages", msgs.len());
    for msg in &msgs {
        println!("  - {}", msg);
    }
}
logged 2 messages
  - process started
  - process finished
RefCell is NOT Thread-Safe

RefCell<T> uses a plain (non-atomic) integer to track borrows. Accessing it from multiple threads simultaneously is a data race. The compiler enforces this — you cannot send a RefCell to another thread.

For multi-threaded interior mutability, use Mutex<T> or RwLock<T> from the standard library.

Type

Thread-safe

Borrow check

Panics

Use when

RefCell<T>

No

Runtime

Yes on violation

Single-threaded interior mutability

Cell<T>

No

N/A (Copy only)

Never

Single-threaded, Copy types

Mutex<T>

Yes

Runtime (blocks)

No (returns Err on poisoning)

Multi-threaded mutation

RwLock<T>

Yes

Runtime (blocks)

No

Multi-threaded, read-heavy mutation

When to Use RefCell
  1. You have a value that logically should be mutable but lives inside a type that is otherwise immutable (e.g. a cache, a counter, or a log).

  2. You are combining with Rc to get Rc<RefCell<T>> — shared mutable data on a single thread.

  3. You are writing test mocks that must record calls through an immutable trait interface.

  4. You are certain the borrow rules are satisfied at the call sites but the compiler cannot statically verify it.

Success
RefCell<T> unlocks interior mutability when the compiler cannot prove borrow safety statically. Combined with Rc, it enables shared mutable state without reaching for unsafe code. Use try_borrow and try_borrow_mut in production code where a panic would be unacceptable.