React in C: Complete Solution & Deep Dive Guide

a close up of a computer screen with code on it

Reactive Programming in C: A Step-by-Step Guide from Zero to Hero

Reactive programming in C is a paradigm for building systems where changing one value automatically propagates updates to all dependent values. This guide provides a complete walkthrough for constructing a basic reactive system, including input cells, compute cells, and callback mechanisms, using fundamental C data structures and manual memory management.


The Spreadsheet Magic: Unveiling Reactivity

Have you ever marveled at the simple elegance of a spreadsheet? You change a single number in a cell, and instantly, like a ripple in a pond, every formula, every chart, and every total that depends on that number updates itself. There's no "recalculate" button to press; the system just reacts. This seemingly magical behavior is the essence of reactive programming.

Many developers associate this paradigm with modern JavaScript frameworks or specialized libraries, believing it's a high-level abstraction far removed from the bare-metal world of C. But what if you could build that very magic from scratch? What if you could use the power and control of C to construct your own reactive engine, understanding every pointer, every memory allocation, and every dependency link that makes it tick?

This is precisely the journey we are about to embark on. This guide will demystify reactive programming by taking you through the exclusive kodikra.com module on building a complete reactive system in C. We will transform abstract concepts into tangible structs and logic flows, giving you a profound understanding that transcends any single language or framework.


What Is a Reactive System?

At its core, a reactive system is an event-driven architecture that automatically propagates changes through a network of dependencies. Instead of writing code that says "when X changes, go and manually update Y and Z," you declaratively define the relationship between them. You state that "Y is the sum of A and B" and "Z is Y multiplied by 2." The system then ensures that whenever A or B changes, Y and Z are updated accordingly, without further intervention.

This model is built upon a few key components:

  • Cells: These are the fundamental data holders. In our system, we'll have two types:
    • Input Cells: These are the source of truth. Their values are set externally by the user or another part of the program.
    • Compute Cells: Their values are derived from other cells (either input or other compute cells). They have an associated computation function that defines this relationship.
  • Dependency Graph: This is an internal, directed graph where nodes are cells and an edge from cell A to cell B means that B's value depends on A's value. When an input cell changes, the change propagates along these edges.
  • Callbacks: These are functions that can be attached to a cell. They are triggered whenever the cell's value changes, allowing the outside world to react to updates within the system.

The Data Flow Diagram

Visualizing the flow of data is crucial. An input cell acts as a source, and compute cells transform and pass that data along. Callbacks are the final consumers of these changes.

  ● Input Cell (Source)
  │   Value: 10
  │
  ▼
┌──────────────────┐
│ Compute Cell 1   │
│ (depends on Input) │
│ Func: input * 2  │
│ Value: 20        │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Compute Cell 2   │
│ (depends on C1)  │
│ Func: C1 + 5     │
│ Value: 25        │
└────────┬─────────┘
         │
         ├──────────────────┐
         │                  │
         ▼                  ▼
  ┌────────────┐     ┌────────────┐
  │ Callback A │     │ Callback B │
  │ (watches C2) │     │ (watches C2) │
  └────────────┘     └────────────┘

Why Build a Reactive System in C?

While languages like JavaScript, Swift, and Kotlin have built-in or library-level support for reactive programming, implementing it in C offers a unique and invaluable learning experience. It forces you to confront the underlying mechanics that higher-level languages abstract away.

The primary reasons to tackle this challenge from the kodikra C learning path are:

  • Mastering Memory Management: You will be responsible for every malloc and free. This builds a deep, practical understanding of memory allocation, pointer arithmetic, and preventing memory leaks—skills that are paramount for any serious systems programmer.
  • Understanding Data Structures: You'll design and implement the core data structures (the struct cell, lists of dependencies and observers) that power the entire system. This is a practical application of data structure theory.
  • Grasping Function Pointers: Compute cells rely heavily on function pointers to store their calculation logic. This project provides a perfect, real-world use case for mastering this powerful but often confusing C feature.
  • Performance and Control: Building the system yourself gives you ultimate control over its performance. You decide on the algorithms for dependency tracking and update propagation, allowing for fine-tuned optimization.

Pros and Cons Analysis

Building such a system in C is a double-edged sword. It offers immense control but comes with significant responsibility.

Pros Cons / Risks
Granular Control: Direct manipulation of memory and data structures allows for highly optimized implementations. Manual Memory Management: High risk of memory leaks, dangling pointers, and segmentation faults if not handled meticulously.
Deep Learning: Provides a fundamental understanding of how reactive frameworks operate under the hood. Complexity: Managing the dependency graph, especially with circular dependencies, can become very complex.
High Performance: No overhead from virtual machines, garbage collectors, or high-level abstractions. Lack of Tooling: No built-in event loops or concurrency primitives; everything must be built from scratch.
Portability: Well-written C code is highly portable across different platforms and operating systems. Boilerplate Code: Requires significantly more code to achieve the same functionality compared to higher-level languages.

How to Design and Implement the Reactive System in C

Our implementation will be broken down into two main files: a header file (reactor.h) defining the public API and data structures, and a source file (reactor.c) containing the core logic.

The Core Data Structures (reactor.h)

The heart of our system is the struct cell. This structure must be flexible enough to represent both input and compute cells. We'll also define function pointer types for computations and callbacks to create a clean, extensible API.

// reactor.h

#ifndef REACTOR_H
#define REACTOR_H

#include <stdbool.h>
#include <stddef.h>

// Forward declaration to allow self-referential pointers.
struct cell;

// Type definitions for function pointers.
// compute_func takes an array of input cell values and returns the new value.
typedef int (*compute_func)(const int* inputs, size_t num_inputs);
// callback_func is triggered when a cell's value changes.
typedef void (*callback_func)(void* arg, int new_value);

// The main reactor structure, which could be used to manage all cells.
// For this implementation, we keep it simple and manage cells individually.
struct reactor {
    // In a more complex system, this might hold a list of all cells for cleanup.
};

// Enum to distinguish between cell types.
enum cell_type {
    INPUT,
    COMPUTE
};

// The core data structure for a cell.
struct cell {
    int value;
    enum cell_type type;
    bool is_dirty; // A flag for optimization to avoid redundant computations.

    // --- Fields for COMPUTE cells ---
    compute_func compute;
    struct cell** dependencies; // Array of cells this one depends on.
    size_t num_dependencies;

    // --- Fields for tracking dependents (observers) ---
    struct cell** observers; // Array of cells that depend on this one.
    size_t num_observers;
    size_t observers_capacity;

    // --- Fields for callbacks ---
    callback_func* callbacks;
    void** callback_args;
    size_t num_callbacks;
    size_t callbacks_capacity;
};

// --- Public API Function Prototypes ---

struct reactor* create_reactor();
void destroy_reactor(struct reactor* reactor);

struct cell* create_input_cell(struct reactor* reactor, int initial_value);
struct cell* create_compute_cell(struct reactor* reactor, struct cell** dependencies, size_t num_dependencies, compute_func compute);

int get_value(struct cell* c);
void set_value(struct cell* c, int new_value);

void add_callback(struct cell* c, void* arg, callback_func cb);
void remove_callback(struct cell* c, void* arg, callback_func cb);

#endif // REACTOR_H

In this header, we define everything another part of our program would need to interact with the reactive system. The struct cell contains fields for its value, type, and a is_dirty flag for lazy evaluation. Compute cells have additional fields for their dependencies and computation function. Crucially, every cell maintains a list of its observers—other cells that need to be notified when it changes.

The Core Logic (reactor.c)

This is where the magic happens. We'll implement the functions declared in our header file. The most complex pieces are create_compute_cell, which sets up the dependency graph, and set_value, which triggers the propagation of changes.

Here is the complete, well-commented implementation.

// reactor.c

#include "reactor.h"
#include <stdlib.h>
#include <string.h>

// --- Helper Functions ---

// A helper to add an observer to a cell's observer list.
// Handles dynamic resizing of the array.
static void add_observer_to_cell(struct cell* target, struct cell* observer) {
    if (target->num_observers >= target->observers_capacity) {
        target->observers_capacity = (target->observers_capacity == 0) ? 4 : target->observers_capacity * 2;
        target->observers = realloc(target->observers, target->observers_capacity * sizeof(struct cell*));
    }
    target->observers[target->num_observers++] = observer;
}

// Recursively marks a cell and all its dependents as 'dirty'.
static void mark_dirty(struct cell* c) {
    if (!c->is_dirty) {
        c->is_dirty = true;
        for (size_t i = 0; i < c->num_observers; ++i) {
            mark_dirty(c->observers[i]);
        }
    }
}

// Recursively triggers callbacks on a cell and its dependents if their values changed.
static void trigger_callbacks(struct cell* c) {
    // For compute cells, the value might not have actually changed after re-computation.
    // A more robust implementation would store the old value and compare.
    // For simplicity, we trigger callbacks if it was marked dirty.
    if (c->is_dirty) { // Simplified condition
        for (size_t i = 0; i < c->num_callbacks; ++i) {
            c->callbacks[i](c->callback_args[i], c->value);
        }
    }

    // Recurse to children
    for (size_t i = 0; i < c->num_observers; ++i) {
        trigger_callbacks(c->observers[i]);
    }
}

// --- Public API Implementation ---

struct reactor* create_reactor() {
    // For this simple version, reactor is a placeholder.
    // A real implementation might initialize memory pools here.
    return malloc(sizeof(struct reactor));
}

void destroy_reactor(struct reactor* reactor) {
    // In a real system, you would free all cells associated with the reactor.
    free(reactor);
}

struct cell* create_input_cell(struct reactor* reactor, int initial_value) {
    (void)reactor; // Unused parameter
    struct cell* c = malloc(sizeof(struct cell));
    *c = (struct cell){
        .value = initial_value,
        .type = INPUT,
        .is_dirty = false,
        .compute = NULL,
        .dependencies = NULL,
        .num_dependencies = 0,
        .observers = NULL,
        .num_observers = 0,
        .observers_capacity = 0,
        .callbacks = NULL,
        .callback_args = NULL,
        .num_callbacks = 0,
        .callbacks_capacity = 0,
    };
    return c;
}

struct cell* create_compute_cell(struct reactor* reactor, struct cell** dependencies, size_t num_dependencies, compute_func compute) {
    (void)reactor; // Unused parameter
    struct cell* c = malloc(sizeof(struct cell));
    *c = (struct cell){
        .value = 0, // Will be computed on first get_value
        .type = COMPUTE,
        .is_dirty = true, // Start as dirty to force initial computation
        .compute = compute,
        .dependencies = malloc(num_dependencies * sizeof(struct cell*)),
        .num_dependencies = num_dependencies,
        .observers = NULL,
        .num_observers = 0,
        .observers_capacity = 0,
        .callbacks = NULL,
        .callback_args = NULL,
        .num_callbacks = 0,
        .callbacks_capacity = 0,
    };

    memcpy(c->dependencies, dependencies, num_dependencies * sizeof(struct cell*));

    // Register this new compute cell as an observer of its dependencies.
    for (size_t i = 0; i < num_dependencies; ++i) {
        add_observer_to_cell(dependencies[i], c);
    }

    // Initial value calculation is deferred until the first `get_value` call.
    return c;
}

int get_value(struct cell* c) {
    if (c->type == COMPUTE && c->is_dirty) {
        int* dep_values = malloc(c->num_dependencies * sizeof(int));
        for (size_t i = 0; i < c->num_dependencies; ++i) {
            dep_values[i] = get_value(c->dependencies[i]);
        }
        
        c->value = c->compute(dep_values, c->num_dependencies);
        free(dep_values);
        c->is_dirty = false;
    }
    return c->value;
}

void set_value(struct cell* c, int new_value) {
    if (c->type != INPUT) {
        return; // Can only set value of input cells
    }
    if (c->value == new_value) {
        return; // No change, no propagation needed
    }

    c->value = new_value;

    // 1. Propagate the 'dirty' flag through the dependency graph.
    for (size_t i = 0; i < c->num_observers; ++i) {
        mark_dirty(c->observers[i]);
    }

    // 2. Trigger callbacks for all affected cells.
    // We need to re-evaluate compute cells to see if their values actually changed.
    // This simplified version triggers callbacks on all dirty cells.
    for (size_t i = 0; i < c->num_observers; ++i) {
        // Re-evaluate the direct dependents to update their values before calling callbacks.
        int old_observer_val = c->observers[i]->value;
        int new_observer_val = get_value(c->observers[i]);
        
        // A more advanced check: only trigger callbacks if value truly changed.
        if (old_observer_val != new_observer_val) {
             for (size_t j = 0; j < c->observers[i]->num_callbacks; ++j) {
                c->observers[i]->callbacks[j](c->observers[i]->callback_args[j], new_observer_val);
            }
        }
    }
}

void add_callback(struct cell* c, void* arg, callback_func cb) {
    if (c->num_callbacks >= c->callbacks_capacity) {
        c->callbacks_capacity = (c->callbacks_capacity == 0) ? 4 : c->callbacks_capacity * 2;
        c->callbacks = realloc(c->callbacks, c->callbacks_capacity * sizeof(callback_func));
        c->callback_args = realloc(c->callback_args, c->callbacks_capacity * sizeof(void*));
    }
    c->callbacks[c->num_callbacks] = cb;
    c->callback_args[c->num_callbacks] = arg;
    c->num_callbacks++;
}

void remove_callback(struct cell* c, void* arg, callback_func cb) {
    // Find the callback to remove
    size_t found_idx = -1;
    for (size_t i = 0; i < c->num_callbacks; ++i) {
        if (c->callbacks[i] == cb && c->callback_args[i] == arg) {
            found_idx = i;
            break;
        }
    }

    // If found, shift elements to fill the gap
    if (found_idx != (size_t)-1) {
        for (size_t i = found_idx; i < c->num_callbacks - 1; ++i) {
            c->callbacks[i] = c->callbacks[i + 1];
            c->callback_args[i] = c->callback_args[i + 1];
        }
        c->num_callbacks--;
    }
}

Code Walkthrough: The Logic of Reactivity

Understanding the code requires breaking down the flow of data and control. The two most important processes are the creation of dependencies and the propagation of updates.

1. Creating a Compute Cell (create_compute_cell)

This function is where the static dependency graph is built. When you create a compute cell, you provide the cells it depends on. The function then iterates through these dependencies and adds the new compute cell to each of their observers lists. This establishes a "forward" link for update propagation.

2. Setting a Value and Propagating Changes (set_value)

This is the dynamic part of the system and the most critical logic flow. When you call set_value on an input cell, a chain reaction is initiated.

Here is a visualization of the update propagation logic:

    ● Start: set_value(input_cell, new_val)
    │
    ▼
  ┌─────────────────────────┐
  │ Update input_cell.value │
  └────────────┬────────────┘
               │
               ▼
  ◆ For each observer of input_cell...
  │
  ├─⟶ ┌───────────────────────────┐
  │   │ mark_dirty(observer)      │
  │   │   (Recursively marks       │
  │   │    its own observers too) │
  │   └───────────────────────────┘
  │
  └─⟶ ┌───────────────────────────┐
      │ get_value(observer)       │
      │   (Forces re-computation  │
      │    if it was marked dirty)│
      └────────────┬──────────────┘
                   │
                   ▼
            ◆ Did value change?
           ╱                   ╲
         Yes                   No
          │                     │
          ▼                     ▼
┌──────────────────┐        [Skip Callbacks]
│ Trigger Callbacks│
│ on this observer │
└──────────────────┘

This process ensures efficiency. A value is only recomputed when it's explicitly requested via get_value and has been marked as dirty (lazy evaluation). This prevents redundant calculations if multiple input cells change before a compute cell's value is actually needed.

Alternative Approaches and Optimizations

The provided solution is a solid foundation, but several alternative designs and optimizations could be explored:

  • Topological Sort for Updates: Instead of simple recursion, a more advanced system could perform a topological sort on the dependency graph. This would ensure that cells are updated in the correct order, preventing a cell from being recomputed multiple times during a single update wave.
  • Error Handling: Our implementation is optimistic. A production-ready version would need robust error handling for memory allocation failures (malloc/realloc returning NULL).
  • Circular Dependency Detection: The current code will enter an infinite loop if a circular dependency is created (e.g., cell A depends on B, and B depends on A). A more robust implementation would detect such cycles during cell creation or update propagation.
  • Memory Pooling: For systems that create and destroy many cells, a custom memory pool allocator could be much more efficient than repeated calls to malloc and free, reducing memory fragmentation.

For more advanced C topics and patterns, you can always refer to our comprehensive C language guide, which covers memory management and data structures in great detail.


Frequently Asked Questions (FAQ)

What is the difference between an input cell and a compute cell?
An input cell is a primary data source; its value is set explicitly from outside the reactive system using set_value. A compute cell's value is derived from other cells; it is defined by a computation function and cannot be set directly.
How do you handle circular dependencies in this system?
The current implementation does not handle circular dependencies and would lead to infinite recursion during a get_value call. A robust solution requires cycle detection, typically using a graph traversal algorithm like Depth First Search (DFS) and tracking the visited nodes in the current recursion stack.
Why are function pointers used for compute cells?
Function pointers provide a powerful mechanism for decoupling the reactive system's logic from the specific computations. They allow users of the system to define any arbitrary computation logic (e.g., addition, multiplication, complex business logic) and pass it into a compute cell, making the system highly flexible and extensible.
Is this implementation thread-safe?
No, this implementation is not thread-safe. Modifying the dependency graph (e.g., creating cells) or setting values from multiple threads simultaneously would lead to race conditions and corrupted data. Making it thread-safe would require adding synchronization mechanisms like mutexes to protect shared data structures like the cell's value and its observer lists.
How can this system be optimized further?
The primary optimization is the use of the is_dirty flag, which enables lazy evaluation. Further optimizations could include using a more efficient data structure for observer lists if they become very large, implementing a topological sort for update propagation to minimize redundant computations, and using a memory pool for cell allocation.
What are some real-world applications of reactive programming?
Reactive programming is the backbone of modern user interfaces (React, Vue, SwiftUI), real-time data streaming and processing (RxJava, RxJS), financial applications for tracking stock prices, and interactive simulations where the state of one object affects many others.
How does this C implementation relate to libraries like RxC?
This implementation covers the "reactive" part but not the "observable stream" part common in Rx (Reactive Extensions) libraries. Rx libraries are more advanced, providing tools to compose, filter, and transform streams of events over time (e.g., mouse clicks, network responses). Our system focuses on the foundational concept of a dependency graph of values.

Conclusion: From Theory to Tangible Skill

You have successfully journeyed through the process of designing and building a reactive programming system from the ground up in C. By manually managing memory, defining data structures, and orchestrating the flow of updates with function pointers, you've gained an insight into this powerful paradigm that few developers possess. The concepts learned here—dependency graphs, observer patterns, and lazy evaluation—are not just academic; they are the fundamental building blocks of countless modern software applications.

This kodikra module demonstrates that even the most complex, "magical" software behaviors can be deconstructed and understood through the disciplined application of core computer science principles. The skills you've honed here will serve you well, whether you continue to work in low-level systems programming or move to higher-level application development.

Technology Disclaimer: The code in this article is written in standard C (C11/C17 compatible) and relies on the standard library functions malloc, realloc, and free. It is designed for educational purposes and should be extended with robust error handling and concurrency controls for production use.

Ready for your next challenge? Continue your journey on the C learning path and discover more advanced projects.


Published by Kodikra — Your trusted C learning resource.