How to Use Rc<RefCell<T>> for Shared Mutable State

Use Rc<RefCell<T>> to share mutable state across multiple owners by combining reference counting with runtime borrow checking.

When shared ownership meets mutation

You are building a UI framework. A button component needs to update a counter, and a label component needs to display that same counter. Both components live in a tree structure. Neither component should own the counter; the counter should outlive both. Rust's ownership rules block this setup immediately. You cannot have two owners. You cannot mutate data through a shared reference. The compiler rejects the code before you can run it.

Rc<RefCell<T>> is the standard solution for this trap. It combines Rc<T> for shared ownership with RefCell<T> for interior mutability. This combination lets you share data across multiple handles and modify it at runtime, while still enforcing Rust's borrowing rules. The trade-off is that borrow checking moves from compile time to runtime. You get the flexibility, but you pay with the risk of a panic if you violate the rules.

The two halves of the problem

Rust separates ownership and borrowing. Ownership determines who cleans up the data. Borrowing determines who can access the data and whether they can change it. Rc and RefCell solve these problems independently.

Rc<T> stands for Reference Counted. It tracks how many owners exist. When you clone an Rc, you create a new handle to the same heap allocation and bump a counter. When an Rc drops, the counter decreases. When the counter hits zero, the data is freed. Rc gives you shared ownership. It does not allow mutation.

RefCell<T> stands for Reference Cell. It tracks borrows at runtime. It allows you to mutate data through a shared reference by checking the borrow rules when you access the data. If you try to borrow mutably while an immutable borrow is active, RefCell panics. RefCell gives you interior mutability. It does not handle ownership.

Wrap a RefCell inside an Rc and you get both. The Rc manages the lifetime of the data. The RefCell manages the access patterns.

Think of Rc as a shared deed to a house. Multiple people can hold a copy of the deed. The house stays alive as long as at least one deed exists. Think of RefCell as a lockbox inside the house. Only one person can open the lockbox at a time. If Alice has the key out, Bob gets locked out until Alice puts it back. The lockbox checks the rule when you try to open it, not when you get the deed.

Minimal example

This example creates a shared counter and mutates it through one handle while reading it through another.

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

fn main() {
    // Rc puts the data on the heap. RefCell wraps it to allow interior mutability.
    // The reference count starts at 1.
    let counter = Rc::new(RefCell::new(0));

    // Clone the Rc to share ownership.
    // This bumps the reference count. It does not copy the integer.
    let handle_a = Rc::clone(&counter);
    let handle_b = Rc::clone(&counter);

    // Mutate through handle_a.
    // borrow_mut checks runtime borrow rules. It returns a RefMut guard.
    // The guard tracks the borrow and drops it when it goes out of scope.
    *handle_a.borrow_mut() += 10;

    // Read through handle_b.
    // borrow checks runtime borrow rules. It returns a Ref guard.
    println!("Value is {}", *handle_b.borrow());
}

The borrow_mut call returns a RefMut guard. This guard holds the mutable borrow. When you dereference it with *, you get access to the inner value. When the guard drops at the end of the statement, the borrow is released. The same applies to borrow, which returns a Ref guard.

How the runtime checks work

RefCell maintains two counters internally: one for active immutable borrows and one for active mutable borrows. The rules mirror the compiler's borrow checker.

When you call borrow, RefCell checks if the mutable borrow counter is zero. If it is, it increments the immutable counter and returns a Ref guard. If the mutable counter is non-zero, it panics with "already mutably borrowed".

When you call borrow_mut, RefCell checks if both counters are zero. If they are, it sets the mutable counter to one and returns a RefMut guard. If either counter is non-zero, it panics with "already borrowed".

The guards implement Drop. When a guard goes out of scope, it decrements the appropriate counter. This ensures borrows are always released, even if you forget to drop them explicitly.

If you try to move a value out of a RefCell, the compiler stops you with E0507 (cannot move out of borrowed content). RefCell only allows borrowing. You can never take ownership of the inner value through a RefCell. This prevents double-free errors even when ownership is shared.

Convention aside: explicit cloning

The community prefers Rc::clone(&value) over value.clone(). Both compile and both work identically. The explicit form signals intent. value.clone() looks like it might deep copy the data. Rc::clone makes it obvious you are cloning the pointer, not the payload. Use the explicit form to help readers scan your code.

Realistic example: a shared task manager

This example shows a TaskManager that shares state across multiple handles. Tasks are stored in a vector, and their status can be updated through shared references.

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

/// Represents a single task with mutable status.
struct Task {
    id: u32,
    status: RefCell<String>,
}

/// Manages a collection of tasks with shared ownership.
struct TaskManager {
    tasks: RefCell<Vec<Rc<Task>>>,
}

impl TaskManager {
    /// Creates a new TaskManager wrapped in Rc.
    fn new() -> Rc<Self> {
        Rc::new(TaskManager {
            tasks: RefCell::new(Vec::new()),
        })
    }

    /// Adds a new task to the manager.
    fn add_task(&self, id: u32) {
        let task = Rc::new(Task {
            id,
            status: RefCell::new("Pending".to_string()),
        });
        // Borrow the tasks vector mutably to push the new task.
        self.tasks.borrow_mut().push(Rc::clone(&task));
    }

    /// Marks a task as done by ID.
    fn complete_task(&self, id: u32) {
        // Iterate over tasks with an immutable borrow of the vector.
        for task in self.tasks.borrow().iter() {
            if task.id == id {
                // Mutate the task status through its RefCell.
                *task.status.borrow_mut() = "Done".to_string();
            }
        }
    }
}

fn main() {
    let manager = TaskManager::new();
    let worker = Rc::clone(&manager);

    worker.add_task(1);
    worker.add_task(2);
    worker.complete_task(1);

    // Verify state through the original handle.
    for task in manager.tasks.borrow().iter() {
        println!("Task {}: {}", task.id, *task.status.borrow());
    }
}

The TaskManager takes &self in its methods. Since self is a shared reference, you cannot mutate the struct fields directly. The RefCell wrappers allow mutation through the shared reference. The tasks field is a RefCell<Vec>, so you can push and iterate. Each Task has a RefCell<String> for its status, so you can update the status without exclusive access to the task.

Notice the borrow scopes. self.tasks.borrow() returns a guard that holds the borrow for the duration of the for loop. The guard drops when the loop ends. Inside the loop, task.status.borrow_mut() borrows the status. The status borrow is independent of the vector borrow. You can mutate a task's status while holding an immutable borrow of the vector, because the status lives inside the task, not inside the vector.

Pitfalls and runtime panics

RefCell panics are the main risk. The compiler cannot catch borrow violations. You will only know if you hit a bad path at runtime.

The most common panic is "already mutably borrowed". This happens when you hold a borrow() and try to borrow_mut(), or when you hold two borrow_mut() calls simultaneously. It also happens if you store a Ref or RefMut guard in a struct field. The guard keeps the borrow alive as long as the struct exists. If you try to borrow again while the guard is stored, you panic.

Keep borrow scopes as short as possible. Drop guards before making recursive calls or nested borrows. If you need to handle contention gracefully, use try_borrow_mut instead of borrow_mut. It returns a Result instead of panicking. You can retry later or handle the error.

Another pitfall is performance. RefCell checks counters on every access. Rc increments and decrements counters on clone and drop. This overhead is small but measurable. In tight loops, RefCell can be slower than direct mutation. Profile before optimizing. If performance is critical, restructure the code to use &mut T where possible.

RefCell is not thread-safe. The counters are not atomic. Sharing an Rc<RefCell<T>> across threads causes data races. Use Arc<Mutex<T>> for concurrent code.

Decision matrix

Use Rc<RefCell<T>> when you need shared mutable state in a single-threaded context and the borrow structure is dynamic or circular. Use Arc<Mutex<T>> when the shared state must cross thread boundaries; Mutex provides the runtime locking required for concurrency. Use &mut T when you have exclusive access and can prove it at compile time; this is the fastest and safest option. Use Rc<T> when you only need shared read access; adding RefCell introduces runtime overhead and panic risk you do not need. Use Cell<T> when you only need to swap whole values atomically and do not need interior references.

Where to go next