diff --git a/.gitignore b/.gitignore index af78b7a..70ec625 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /modules /luacov.* -/installer.lua \ No newline at end of file +/installer.lua +site/ diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..5d07f49 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,138 @@ +# Rodux API Reference + +## Store +The Store class is the core piece of Rodux. It is the state container that you create and use. + +### Store.new +```lua +Store.new(reducer, initialState, middlewares) -> Store +``` +Creates and returns a new store. + +`reducer` is the store's root reducer function. `initialState` is the store's initial state. This should be used to load a saved state from storage. `middlewares` is a table of middlewares to apply. + +The store will automatically dispatch an initialization action with a `type` of `@@INIT`. + +!!! note + The initialization action does not pass through middlewares prior to reaching the reducer. + +### Store.changed +```lua +Store.changed:connect(function(newState, oldState) + -- do something with newState or oldState +end) +``` +A [Signal](#Signal) that is fired when the store's state is changed. + +!!! danger + Do not yield within any listeners on `changed`; an error will be thrown. + +### Store:dispatch +```lua +Store:dispatch(action) -> nil +``` +Dispatches an action. The action will travel through all of the store's middlewares before reaching the store's reducer. + +The action must contain a `type` field to indicate what type of action it is. No other fields are required. + +### Store:getState +```lua +Store:getState() -> table +``` +Gets the store's current state. + +!!! warning + Do not modify this value. Doing so may cause serious bugs in Rodux, your code, or both! + +### Store:destruct +```lua +Store:destruct() -> nil +``` +Destroys the store, disconnecting all connections it may possess. + +!!! danger + Attempting to use the store after `destruct` has been called will cause problems. + +### Store:flush +```lua +Store:flush() -> nil +``` +Flushes the store's pending actions, firing the `changed` event. You should not need to use this method; it is called automatically from within Rodux. + +## Signal +The Signal class in Rodux represents a simple, predictable event that is controlled from within Rodux. It is not publicly exposed and cannot be created outside of Rodux. + +### Signal:connect +```lua +Signal:connect(listener) -> { disconnect } +``` +Connects a listener to the signal. The listener will be invoked whenever the signal is fired. + +`connect` returns a table with a `disconnect` function that can be used to disconnect the listener from the signal. + +## Helper functions +Rodux supplies some helper functions to make creating complex reducers easier. + +### combineReducers +A helper function that can be used to combine reducers. It is exposed as `Rodux.combineReducers`. + +```lua +local reducer = combineReducers({ + key1 = reducer1, + key2 = reducer2, +}) +``` + +`combineReducers` is functionally equivalent to writing: + +```lua +local function reducer(state, action) + return { + key1 = reducer1(state.key1, action), + key2 = reducer2(state.key2, action), + } +end +``` + +### createReducer +A helper function that can be used to create reducers. It is exposed as `Rodux.createReducer`. + +```lua +local reducer = createReducer({ + action1 = function(state, action) + -- Handle actions of type "action1" + end, + action2 = function(state, action) + -- Handle actions of type "action2" + end, + -- ... +}) +``` + +## Middleware +Rodux ships with several middlewares that address common use-cases. + +### loggerMiddleware +```lua +loggerMiddleware(outputFunction = print) -> middlewareFunction +``` +A middleware that logs actions and the new state that results from them. It is exposed as `Rodux.loggerMiddleware`. + +This middleware supports changing the output function. By default, it is `print`, and state changes are printed to the output. As a consequence of this, `loggerMiddleware` must be called when using it: + +```lua +local store = Store.new(reducer, initialState, { loggerMiddleware() }) +``` + +### thunkMiddleware +A middleware that allows thunks to be dispatched. Thunks are functions that perform asynchronous tasks or side effects, and can dispatch actions as needed. It is exposed as `Rodux.thunkMiddleware`. + +```lua +local store = Store.new(reducer, initialState, { thunkMiddleware }) +store:dispatch(function(store) + print("Hello from a thunk!") + store:dispatch({ + type = "thunkAction" + }) +end) +``` diff --git a/docs/guide/actions.md b/docs/guide/actions.md new file mode 100644 index 0000000..8ec35cf --- /dev/null +++ b/docs/guide/actions.md @@ -0,0 +1,36 @@ +# Actions +**Actions** are bundles of data sent from your game to your store. They are tables with a single required field: `type`. The `type` field is used to describe the type of the action, which determines what it does. It is recommended, but not required, that `type` be a string, for easy debugging. You can dispatch actions to the store using its [dispatch](../api-reference.md#storedispatch) method. + +Here's an example action that tells the store that a player joined: + +```lua +local PLAYER_JOIN = "playerJoin" + +local action = { + type = PLAYER_JOIN, + player = newPlayer, +} +``` + +!!! question + #### Do types have to be defined as variables first? + No, not at all! It is a good practice, however, because it is easier to change type names and to catch mistakes. + +## Action creators +**Action creators** are functions that create actions for you. This allows you to validate your action arguments before the action is dispatched, add extra data to the action, and normalize your inputs. Here's an action creator for the player join action above: + +```lua +local function playerJoin(player) + return { + type = PLAYER_JOIN, + player = player, + } +end +``` + +And here's it in use: + +```lua +-- somewhere inside an event handler +store:dispatch(playerJoin(player)) +``` diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md new file mode 100644 index 0000000..4468d0d --- /dev/null +++ b/docs/guide/middleware.md @@ -0,0 +1,171 @@ +# Middleware +Middleware is a way to modify the behavior of a store by altering the dispatch behavior. Middlewares can modify an action, consume it entirely (stopping it from being dispatched), or just do something else on top of the action. They do this by overriding the store's `dispatch` method entirely. A middleware is a factory for new `dispatch` methods, to rephrase things. + +## Using middlewares +Middlewares can be used by specifying them in an array in the third argument of `Store.new`: + +```lua +local store = Rodux.Store.new( + reducer, + initialState, + { middleware3, middleware2, middleware1 } +) +``` + +!!! question + #### Why are the middlewares in reverse order? + Rodux evaluates its middlewares in last-in-first-out order: the last argument is the one that's invoked first. The order of your middlewares is important. + +Once you've done this, the middlewares are active and will take effect whenever you use the store's `dispatch` method. + +## Built-in middlewares +Rodux comes with two built-in middlewares: `loggerMiddleware` and `thunkMiddleware`. + +### loggerMiddleware +`loggerMiddleware` is a very simple middleware that lets you log changes to your state. It is exposed as `Rodux.loggerMiddleware` from the main Rodux module. Whenever an action is dispatched, it will print two things: + +* The action in its entirety +* The state after the action was reduced + +To use it, specify it like this: + +```lua +local store = Rodux.Store.new( + reducer, + initialState, + { Rodux.loggerMiddleware() } +) +``` + +!!! question + #### Why is `loggerMiddleware` called? + `loggerMiddleware` is called because it allows you to change the function used to print to the output. For example, if you wanted to print all the changes to your store as warnings, you could do this: + + ```lua + local store = Rodux.Store.new( + reducer, + initialState, + { Rodux.loggerMiddleware(warn) } + ) + ``` + +Now, whenever you dispatch an action, you'll see something like the following in the output window: + +``` +Action dispatched: { + type = "test"; (string) + payload = 1; (number) + } +State changed to: { + testValue = 1; (number) + } +``` + +### thunkMiddleware +`thunkMiddleware` is a middleware that lets you use thunks - it lets you dispatch a function to your store, which will be run. The function can do anything, and can dispatch new actions at will. Thunks are commonly used for asynchronous, long-running operations, like reading from a data store or performing a HTTP request. + +To use it, just include it in your `middlewares` table: + +```lua +local store = Rodux.Store.new( + reducer, + initialState, + { Rodux.thunkMiddleware } +) +``` + +Once you've done that, you can dispatch a function just like you would an action with the store's `dispatch` method: + +```lua +store:dispatch(function(store) + -- Do something that takes a while + + -- Then dispatch an action to tell the store about the result! + store:dispatch({ + type = "someAction" + }) +end) +``` + +## Writing your own middlewares +There's nothing magic about writing middlewares! Here's how you can write your own. + +### A simple example: printing the type field +Here's a simple middleware that just prints the action's `type` field: + +```lua +local function printType(next) + return function(store, action) + print(action.type) + next(store, action) + end +end +``` + +Breaking it down: + +* `printType` is a function that takes one argument: `next`. This is the next middleware in the chain. At the end of the chain lies the original `dispatch` method. +* `printType` returns a new function that takes two arguments: `store` and `action`. These arguments are the *exact signature* of the original `dispatch` method. +* The function returned from `printType` prints the action's type, then calls `next` to pass the action on. + +To use this function, specify it in the third argument to `Store.new`: + +```lua +local function reducer(state, action) + -- Just return the same state, for demonstrational purposes. + return state +end + +local store = Store.new(reducer, {}, { printType }) + +store:dispatch({ + type = "testAction" +}) +``` + +Run this code and you'll see this in the output: +``` +testAction +``` + +### Canceling actions +Nothing says you *have* to call `next` at all! Here's a middleware that just swallows up any action that it comes across. These actions never modify the store's state. + +```lua +local function swallowAction(next) + return function(store, action) + -- Do nothing! Since next is not called, the action never moves on. + end +end +``` + +### Modifying actions: PascalCased Type +Similarly, you don't always have to call `next` with the same action. Say you prefer using `PascalCase` for your actions. Rodux requires that your actions have a `type` field, so your code style is being broken! Middlewares to the rescue - you can replace the action so that it fits the structure Rodux is expecting, without having to make compromises about your casing. + +```lua +local function pascalCaseType(next) + return function(store, action) + -- If the action has a Type field, substitute it with an identical action + -- that substitutes type for Type! + if action.Type then + local newAction = {} + + for key, value in pairs(action) do + -- Change the casing on the Type field + if key == "Type" then + newAction.type = value + -- Everything else can stay as-is + else + newAction[key] = value + end + end + + -- Pass the new action on! + next(store, newAction) + -- Otherwise, just send the action on! + else + next(store, action) + end + end +end +``` diff --git a/docs/guide/modularity.md b/docs/guide/modularity.md new file mode 100644 index 0000000..7a06c7a --- /dev/null +++ b/docs/guide/modularity.md @@ -0,0 +1,55 @@ +# Modularity +Despite Rodux's requirement that your state be centralized in a single object, modular code is still possible! + +A lot of the time, you'll have a bunch of separate blocks of state: + +```lua +{ + market = { + -- state about the market + }, + round = { + -- state about the round + }, + players = { + -- state about players in the game + }, + -- ...and so forth +} +``` + +You can write a reducer that works like this: + +```lua +local function reducer(state, action) + return { + market = marketReducer(state.market, action), + round = roundReducer(state.round, action), + players = playersReducer(state.players, action), + } +end +``` + +The individual reducer functions can come from separate modules anywhere in your game - there's nothing special about them. They're just like any other reducer. Rodux provides a super-simple function that can make combining these reducers easier: `combineReducers`. It works like this: + +```lua +local reducer = Rodux.combineReducers({ + market = marketReducer, + round = roundReducer, + players = playersReducer, +}) +``` + +This is exactly the same as the reducer from before! Further, nothing stops you from combining reducers in multiple layers, like this: + +```lua +local reducer = Rodux.combineReducers({ + market = Rodux.combineReducers({ + sell = marketSellReducer, + buy = marketBuyReducer, + bank = marketBankReducer, + }), +}) +``` + +Rodux's only constraint on your code is that at the end you have to glue everything together into one function. How you structure the rest of your code is entirely up to you. diff --git a/docs/guide/reducers.md b/docs/guide/reducers.md new file mode 100644 index 0000000..f79a6f0 --- /dev/null +++ b/docs/guide/reducers.md @@ -0,0 +1,85 @@ +# Reducers +A **reducer** is a function that transforms the [state](./state.md) in response to [actions](./actions.md). Reducers should be pure functions - given the same inputs, they should always have the same output. The reducer signature is: + +``` +reducer(state, action) -> newState +``` + +Reducers take the current state and an action, and return a new state table. It is important that the reducer **does not mutate the state**. It should return a new state, instead. This means that this reducer, which changes its `state` argument, is incorrect: + +```lua +local function reducer(state, action) + state.value = action.value + + return state +end +``` + +Instead of writing this, you should instead write: + +```lua +local function reducer(state, action) + local newState = {} + + for key, value in pairs(state) do + newState[key] = value + end + + newState.value = action.value + + return newState +end +``` + +## Initializing state: the `@@INIT` action +When you first create a store, Rodux will automatically dispatch an action with a type of `@@INIT` before any other actions are dispatched. Your reducer should use this action to set up the state correctly: + +```lua +local function reducer(state, action) + if action.type == "@@INIT" then + return { + value = "default", + -- ... + } + end + + -- Do something with the action +end +``` + +!!! question + #### If there's an initialization action, what's the point of supplying an initial state? + They do different things! The initialization action is for setting up parts of your state that need to be set up at run-time. The initial state that you can supply when creating a store is for loading your state from storage. + +## Handling more than one type of action +Most of the time, your reducer needs to handle many types of actions. You can use the `type` field of the action for this: + +```lua +local function reducer(state, action) + if action.type == "someAction" then + -- Do something with this action + elseif action.type == "otherAction" then + -- Do something with another action + else + -- Don't know how to handle this action; don't change the state at all + return state + end +end +``` + +!!! question + #### Why is the reducer returning the same state value if it can't handle the action? + You're allowed to return the same state **provided it has not changed**. If the state doesn't change at all, the existing state is still valid, and can be returned from the reducer. + +Writing out `if-elseif-else` blocks can be inconvenient at times. To simplify these constructs, Rodux provides a `createReducer` function. This `createReducer` call is equivalent to the reducer above: + +```lua +local reducer = Rodux.createReducer({ + someAction = function(state, action) + -- Do something with someAction + end, + otherAction = function(state, action) + -- Do something with otherAction + end, +}) +``` diff --git a/docs/guide/state.md b/docs/guide/state.md new file mode 100644 index 0000000..9f19647 --- /dev/null +++ b/docs/guide/state.md @@ -0,0 +1,29 @@ +# State +In Rodux, the **state** of your game is one large table that contains everything that is currently happening. The state is the *single source of truth* in your game. This makes it easy to: + +* Save and load state +* Debug your game +* Undo/redo actions + +## What to put in state +Anything that changes in your game should go in your state table. You should be able to look at your state table and determine exactly what was going on at a given point in time, without knowing anything else. + +Constants and configuration values that don't ever change in your game don't need to go in your state table. + +## State is read-only +In Rodux, state is **read-only** - it cannot be changed directly. The only way to change your state is by dispatching an [action](./actions.md). This means that every change to your state flows down a single path, which can be easily manipulated. Logging state changes, for example, only needs to happen in one place, instead of sprinkling `print` statements across your entire codebase. + +!!! question + #### Why is state immutable? + State is immutable because it makes it very, very easy to determine if state changed. If state was mutable, you would need to traverse the entire state tree, which can be very slow in large games, to find out if something changed. Since state is immutable, you can just do something like: + + ```lua + if state ~= oldState then + -- State changed, do something! + end + ``` + + This also makes it very easy to undo an action - just save the old state value, and replace the current state with the old state. That state will always be valid, since it's never changed, only replaced! + +## State is updated using pure functions +Since state is read-only, the way to change it is with **pure functions**, known in Rodux as [reducers](./reducers.md). Reducers take the old state and an action, and return a brand-new state table. They do not change the old state - they make a new state table. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d1a6bbf --- /dev/null +++ b/docs/index.md @@ -0,0 +1,2 @@ +# Home +Rodux is a central state management library that heavily mirrors Facebook's [Redux](http://redux.js.org/) library. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..1f5ef91 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,27 @@ +site_name: Rodux Documentation +repo_name: Roblox/rodux +repo_url: https://github.com/Roblox/rodux + +theme: + name: material + palette: + primary: 'Light Blue' + accent: 'Light Blue' + +pages: + - Home: index.md + - Guide: + - State: 'guide/state.md' + - Actions: 'guide/actions.md' + - Reducers: 'guide/reducers.md' + - Modularity: 'guide/modularity.md' + - Middleware: 'guide/middleware.md' + - 'API Reference': api-reference.md + +markdown_extensions: + - admonition + - codehilite: + guess_lang: false + - toc: + permalink: true + - pymdownx.superfences diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f79c751 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +mkdocs +mkdocs-material +pymdown-extensions