Understanding Actors in Caper
Actors are the fundamental building blocks in Caper. They encapsulate state and behavior, processing messages sequentially to manage state changes and UI updates. This concept, borrowed from the Actor model of concurrent computation, simplifies state management in React applications.
What is an Actor?
In the context of Caper, an Actor is essentially a JavaScript function that returns an object. This returned object must contain a single method: receive(). The actor function itself is where you define its internal state (variables) and any helper functions it might need. These are kept private within the actor's closure.
The receive() method is the public interface of the actor. It's the sole entry point for all messages destined for that actor. These messages can represent user interactions, data fetching results, or any other event that might affect the component's state.
Key characteristics of Caper actors:
- Encapsulated State: State variables are local to the actor function, preventing direct manipulation from outside.
- Message-Driven: Actors react to incoming messages. All state changes occur as a result of processing a message.
- Sequential Processing: Messages are processed one at a time by the
receive()method, eliminating race conditions and complex synchronization logic. - UI Rendering: After processing a message, the actor typically constructs the UI (as JSX) and calls
draw(ui)to update the component.
Creating an Actor in Caper
Creating an actor in Caper involves defining a function that adheres to a specific structure. Let's break down the anatomy of a Caper actor:
1. The Actor Function
This is a regular JavaScript function. Its main responsibilities are:
- Initializing state variables.
- Accessing Caper's
tools(deliveranddraw). - Returning an object with a
receivemethod.
2. The Mailbox Type
Before defining the actor, you typically define a TypeScript type for its "mailbox". This is a union type representing all possible messages the actor can receive. Each message is usually a tuple, [name, value?], where name is a string literal identifying the message type, and value is an optional payload.
import type { PropsMessage } from "@matthewp/caper";
// Define the Mailbox type for an actor.
// This union type lists all possible messages the actor can receive.
type MyActorMailbox =
// For initial props
| PropsMessage<{ initialCount?: number }>
// Message to increment the count, React.MouseEvent is passed automatically from onClick
| ['increment', React.MouseEvent<HTMLButtonElement, MouseEvent>]
// Message to decrement the count, React.MouseEvent is passed automatically from onClick
| ['decrement', React.MouseEvent<HTMLButtonElement, MouseEvent>]
// Message to set the count to a specific value
| ['set', number]
// Example of a message with a payload
| ['api/dataLoaded', { data: string }]
// Example of a message from a form
| ['form/submit', FormData];
The PropsMessage<T> type is a special message Caper uses to deliver initial props to your component.
3. The tools Object
Inside your actor function, you'll call tools<Mailbox>(). This function, provided by Caper, returns an object with two essential methods:
-
deliver(name, value?): This is a higher-order function. Callingdeliver('messageName')returns *another function* (a thunk). This returned function is what you'll typically use as an event handler (e.g.,onClick={deliver('increment')}) or as a callback (e.g., for promises). When this returned function is called (for example, by an event like a click, or by a promise resolving), the value it receives (like the browser'sEventobject or the promise's resolved value) becomes thevalue(the second element in your message tuple:['messageName', receivedValue]).
For instance, if you haveonClick={deliver('increment')}, when the button is clicked, theReact.MouseEventobject from the click event is automatically passed, and your actor receives['increment', mouseEvent]. Similarly, you can use it with promises:fetchData().then(deliver('dataLoaded')). When thefetchDatapromise resolves, the resolved data will be sent to your actor as['dataLoaded', resolvedData].
Caper leverages TypeScript to provide strong typing for these messages. If the type of the value passed to the returned function (e.g., theEventobject or promise resolution) doesn't match the expectedvaluetype in your mailbox definition for that message, you'll get a type error. This also applies if you try todelivera message name not defined in your mailbox, helping to catch errors early.
If you need to send a message with a specific value directly from your actor's code, without an event or promise, you would call the returned function immediately:deliver('set')(10). This would send['set', 10]to the actor. draw(ui): A function you call after processing a message and updating state. It takes the new JSX UI and tells Caper to render it.
4. The receive([name, value]) Method
This is the core of your actor. It takes one argument: the incoming message (a tuple matching your mailbox type). Inside receive:
- You typically use a
switchstatement onnameto handle different message types. - Update your actor's internal state variables based on the message.
- Construct the JSX for your component's UI.
- Call
draw(ui)to update the view.
Putting It All Together: An Example Actor
function myActor() {
// State variables are defined here, within the actor's closure.
let count = 0;
// The 'tools' object provides 'deliver' and 'draw'.
// 'deliver' is used to send messages back to this actor.
// 'draw' is used to re-render the UI.
const { deliver, draw } = tools<MyActorMailbox>();
// The heart of the actor: the receive method.
return {
receive([name, value]: MyActorMailbox) {
// Use a switch statement to handle different message types.
switch(name) {
case 'increment':
count++;
break;
case 'decrement':
count--;
break;
case 'set':
count = value as number; // Type assertion might be needed
break;
case 'props':
// Handle incoming props if your component needs them
// For example, initialCount = value.initialCount;
break;
}
// After processing the message and updating state,
// construct the UI and tell Caper to draw it.
const ui = (
<div>
<p>Count: {count}</p>
<button onClick={deliver('increment')}>Increment</button>
<button onClick={deliver('decrement')}>Decrement</button>
</div>
);
draw(ui);
}
};
} From Actor to React Component
Once you've defined your actor function, Caper provides the createComponent utility to turn it into a usable React component.
import { createComponent } from "@matthewp/caper";
// Assuming 'myActor' and 'MyActorMailbox' are defined as above.
// Use createComponent to transform your actor function into a React component.
export const MyCounterComponent = createComponent(myActor);
// Now you can use MyCounterComponent like any other React component:
// <MyCounterComponent initialCount={10} />
The createComponent function handles the integration with React, ensuring that your actor's receive method is called when messages are sent and that the UI is updated when you call draw().
Benefits of Caper's Actor Approach
This actor-centric architecture brings several advantages:
- Simplicity: State is managed with plain JavaScript variables and logic. No complex hooks or reactive wrappers are needed within the actor.
- Predictability: All state changes flow through a single, well-defined point (
receive()), making it easier to trace how and why state changes. Sequential message processing avoids race conditions. - Testability: Actors can be tested in isolation by sending them messages and asserting the resulting state or UI structure (if you choose to expose parts of it for testing).
- Clear Separation of Concerns: The actor clearly separates state logic from UI rendering, although they are managed within the same functional scope.
By understanding and utilizing actors effectively, you can build robust and maintainable React applications with Caper, enjoying a simpler and more direct way to manage state.