Combining Rc and RefCell for Shared Mutable State
Learn how to combine Rc and RefCell in Rust to create data with multiple owners that can all mutate the value, and when this pattern is useful and safe.
You have seen Rc<T> and RefCell<T> in isolation. Rc<T> gives you multiple owners, but only for shared (immutable) access. RefCell<T> gives you interior mutability — mutation through a shared reference — but only for a single owner. On their own, each solves one half of a common problem: you want several parts of your program to hold on to the same piece of data and be able to change it.
Rc<RefCell<T>> is the standard answer for single‑threaded Rust programs that need shared mutable state. The outer Rc handles shared ownership and keeps the data alive as long as any owner exists. The inner RefCell moves borrow checking from compile time to runtime, so each owner can call borrow_mut() and write to the value, as long as no other owner is simultaneously reading or writing it.
The gap each type leaves on its own
A quick example makes the problem concrete. Suppose you have a GameState and two objects that need to update a shared score:
use std::rc::Rc;
struct GameState {
score: i32,
}
fn main() {
let state = Rc::new(GameState { score: 0 });
let player = Rc::clone(&state);
let enemy = Rc::clone(&state);
// Neither can mutate the score through Rc
// player.score += 10; // error: cannot assign to data in an `Rc`
}
Rc<T> only hands out shared references (&T). The moment you try to write through it, the compiler stops you. The borrow checker is doing its job — at compile time there is no way to prove that no other Rc clone is also reading the data.
RefCell<T> alone would let you mutate through a shared reference, but it does not give you multiple owners. If you pass a RefCell<GameState> to both player and enemy, ownership is still singular. You would have to move it, not share it.
The missing piece:
The compiler enforces the rule “one mutable reference XOR any number of shared references” at compile time for normal references. Rc respects that rule by only giving shared access. RefCell postpones the check to runtime — it still enforces the rule, but it does so with counters that can panic. When you combine them, you get runtime‑enforced borrowing on data that lives as long as any owner holds it.
How Rc<RefCell<T>> works mechanically
The outer Rc allocates the RefCell<T> on the heap and maintains a strong reference count. Every Rc::clone increments that count, and when the last clone drops, the heap allocation is freed.
The inner RefCell stores a counter of outstanding borrow() calls (immutable) and a flag for an outstanding borrow_mut() call (mutable). When you call borrow_mut() on a RefCell, it checks at runtime that no other borrows (immutable or mutable) are active. If the check passes, it hands out a RefMut<T>, which acts like a mutable reference and decrements the counter when it goes out of scope.
So the full flow for a mutation looks like this:
- You have an
Rc<RefCell<T>>. - Dereference the
Rcto get to theRefCell. - Call
.borrow_mut()on theRefCell. - The runtime checks that the current borrow count is zero. If it isn’t, the program panics.
- If the check passes, you get a
RefMut<T>that allows mutation. When that guard drops, the mutable borrow is released.
Building shared mutable state — step by step
The example below creates a shared list of players that several parts of the program can modify. It shows the full lifecycle from creation to mutation through different owners.
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
// Step 1: Create the shared, mutable container
let roster = Rc::new(RefCell::new(vec!["Alice".to_string()]));
// Step 2: Clone the Rc to simulate multiple owners
let game_logic = Rc::clone(&roster);
let ui = Rc::clone(&roster);
// Step 3: Mutate through one owner
{
let mut names = game_logic.borrow_mut();
names.push("Bob".to_string());
} // borrow_mut ends here, guard dropped
// Step 4: Read through another owner — the mutation is visible
{
let names = ui.borrow();
println!("UI sees: {:?}", *names); // ["Alice", "Bob"]
}
// Step 5: Original owner can also read the final state
println!("Final roster: {:?}", *roster.borrow());
}
The scoping with curly braces is important. The mutable borrow must end before any immutable borrow can be taken. If you hold a RefMut while trying to call borrow(), the runtime check in RefCell will see an active mutable borrow and panic.
Runtime panics instead of compile errors:
This code compiles fine, but it panics at runtime:
let data = Rc::new(RefCell::new(5));
let m = data.borrow_mut(); // mutable borrow active
let r = data.borrow(); // panics: already mutably borrowed
The compiler cannot save you here. The guard m is still in scope when the immutable borrow is attempted, so the RefCell’s runtime counters trigger a panic.
Why this pattern is so common
In real Rust programs, Rc<RefCell<T>> appears whenever you have an object graph that needs to be mutated after construction, without clear single‑ownership. Common cases include:
- Configuration shared across components — each component holds an
Rc<RefCell<Config>>and can update a field (logging level, retry count) dynamically. - Observer / event systems — listeners keep a shared reference to a log or state vector that they append to through an immutable trait method (
&self). - Tree and graph structures — nodes point to children via
Rc<RefCell<Node>>so that a parent can add or remove children even while other parts of the program are looking at the same nodes.
The following example models a simple observer pattern. The Observer trait’s method takes &self, yet we want the observer to record events internally. RefCell makes that possible without changing the trait signature.
use std::cell::RefCell;
use std::rc::Rc;
trait Observer {
fn notify(&self, event: &str);
}
struct EventRecorder {
events: RefCell<Vec<String>>,
}
impl EventRecorder {
fn new() -> Self {
EventRecorder {
events: RefCell::new(Vec::new()),
}
}
fn recorded(&self) -> Vec<String> {
self.events.borrow().clone()
}
}
impl Observer for EventRecorder {
fn notify(&self, event: &str) {
// This is the key: we mutate through &self
self.events.borrow_mut().push(event.to_string());
}
}
struct Subject {
observers: Vec<Rc<dyn Observer>>,
}
impl Subject {
fn new() -> Self {
Subject { observers: vec![] }
}
fn register(&mut self, observer: Rc<dyn Observer>) {
self.observers.push(observer);
}
fn fire(&self, event: &str) {
for obs in &self.observers {
obs.notify(event);
}
}
}
fn main() {
let recorder = Rc::new(EventRecorder::new());
let mut subject = Subject::new();
subject.register(recorder.clone());
subject.fire("event_A");
subject.fire("event_B");
println!("Events: {:?}", recorder.recorded()); // ["event_A", "event_B"]
}
Because EventRecorder implements Observer, its notify method must match fn notify(&self, …). Without RefCell, there would be no way to push into self.events — you would need &mut self, which would break the trait contract. RefCell allows the mutation inside an outwardly immutable method.
Borrowing discipline still exists — it just moved to runtime
The mental model that helps beginners is: RefCell does not make borrowing rules disappear; it replaces compiler errors with runtime panics. When you hold a RefMut<T> guard, you have the same exclusive access a &mut T would give you. If you (or another owner through a different Rc clone) try to create a Ref<T> guard at the same time, the RefCell will panic.
The safe way to work with the pattern is to keep borrows as short as possible — always use explicit blocks so that RefMut and Ref guards are dropped before the next borrow.
Beware of long‑lived guards:
A RefMut held in a variable that spans many lines of code will block all other borrows, including reads. This can lead to panics that are hard to trace. Prefer to borrow, mutate, and immediately drop the guard:
// GOOD: borrow is limited to a single expression
data.borrow_mut().push(42);
// RISKY: guard lives until end of scope
let mut guard = data.borrow_mut();
// ... many lines of code ...
drop(guard); // must explicitly drop before another borrow
A subtle edge: try_borrow_mut and graceful handling
If you cannot guarantee that no other borrow exists, use try_borrow_mut() instead of borrow_mut(). It returns a Result instead of panicking, letting you decide what to do when the data is already borrowed.
if let Ok(mut val) = data.try_borrow_mut() {
*val += 1;
} else {
eprintln!("Data is currently borrowed, skipping update");
}
This is useful in complex object graphs where multiple components might attempt to mutate the same RefCell at unpredictable times — the program can log a warning and continue instead of crashing.
Correct usage is safe:
When used with short borrows, Rc<RefCell<T>> provides the exact same memory safety guarantees as ordinary references — there are no data races (in single‑threaded code) and no dangling pointers. The only difference is when invalid usage is detected.
The cycle risk
Every Rc<RefCell<T>> graph that has a cycle — two nodes pointing to each other through Rc — will leak memory. The reference counts never drop to zero, so the heap allocation is never freed.
For now, remember: the combination is a powerful tool for single‑threaded Rust, but it shifts responsibility from the compiler to you. When you pick up Rc<RefCell<T>>, you are agreeing to think about runtime borrow patterns and to avoid cycles — just as you would manually manage memory in another language, but with the safety net of panics instead of silent corruption.