Skip to content

Ideas from SunshineJS #780

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
hallettj opened this issue Sep 23, 2015 · 4 comments
Closed

Ideas from SunshineJS #780

hallettj opened this issue Sep 23, 2015 · 4 comments

Comments

@hallettj
Copy link

This is not a bug report or a specific feature suggestion. I have been working on my own framework, SunshineJS, which is based on principles very similar to those in Redux (because those principles are awesome - I love the attention that Redux is getting). I have come up with ideas in Sunshine that seem to me to work well, and I wanted to start a discussion upstream in case any of those ideas are interesting to people here.

In my view the biggest difference between Sunshine and Redux is that I wrote Sunshine specifically to take advantage of typechecking with Flow. In any case where there was a design decision to be made, I went with the implementation that tended to result in informative type errors when things go wrong. I saw that there is some discussion of using Flow in #290. I'm hoping that my experimentation is helpful in that discussion.

Events as classes

Instead of using string constants and action-creator functions, Sunshine uses classes to define action types and parameters associated with actions. I think that this has a few advantanges:

  • classes are very amenable to type-checking
  • action types are scoped by module - there is no danger of conflicts if two
    components happen to use the same name for an action type
  • action types and actions are described in one place

Classes allow a nice pattern for implementing reducers. Using the TodoMVC example as a reference, if the addTodo action is implemented like this (with Flow type annotations):

class AddTodo {
  text: string;
  constructor(text: string) { this.text = text }
}

then the idiomatic Sunshine reducer looks like this:

app.on(AddTodo, (state, { text }) => {
  return [{
    id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
    completed: false,
    text: text
  }, ...state]
})

The on method takes an action type and a reducer function. (Note that the second argument to the reducer uses destructuring to get the text field out of an AddTodo instance.) on serves the dual-functions of invoking a reducer only if the incoming action matches the given type, and of allowing the type checker to verify that the reducer action argument matches the structure of that action type. The implementation of on looks like this:

export class App<AppState> {

  /* ... */

  on<Event: Object>(klass: Class<Event>, handler: (state: AppState, event: Event) => AppState) {
    this._handlers.push([klass, handler])
  }

}

Because the type of the handler argument references the AppState and Event types, the type checker is able to ensure that the reducer is compatible with those types. Because the Event variable appears in both handler type and in the action-type type, the type checker can verify that that the reducer matches up with the specific action type that it is registered for.

This means that if someone removes a parameter from an action, or changes the type of a parameter, the type checker will point to any reducer implementations that have to be updated. The same goes for changes to the shape/type of the app state.

Sunshine uses an App instance and routes everything through it. I imagine if the idea of actions-as-classes were adapted for Redux a reducer might look more like this:

export default var todos = compose(
  reducer(AddTodo, (state = initialState, action) => {
    return [{
      id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
      completed: false,
      text: action.text
    }, ...state]
  }),

  reducer(DeleteTodo, (state = initialState, action) => {
    /* etc */
  }),

  /* etc */
)

React.Component subclass

Sunshine implements a subclass of React.Component to wire things in a React-ish way. The Sunshine.Component subclass expects a Sunshine.App instance to be given either as a prop (in the case of a top-level component) or via context (for child components). (A Sunshine component that gets access to an app instance will automatically relay that instance to child components via context.) In my opinion this allows low-boilerplate patterns for connecting state to components, and for dispatching actions. In particular, state is hooked up by implementing a method, and there is no need to explicitly pass an actions map to child components.

Setting component state

Adapting the TodoMVC example again, state is relayed to a component via a getState method:

type Todo = { id: number, completed: boolean, text: string }
type AppState = { todos: Todo[], filter?: string }
type ComponentState = { todos: Todo[], filter?: string }

// The type parameters are: Sunshise.Component<DefProps,Props,ComponentState>

class MainSection extends Sunshine.Component<{},{},ComponentState> {
  getState(appState: AppState): ComponentState {
    // In a larger app there might be more transformation here
    return appState
  }

  /* ... */

  render(): React.Element {
    var { todos, filter } = this.state

    var filteredTodos = todos.filter(TODO_FILTERS[filter])
    var completedCount = todos.reduce((count, todo) =>
      todo.completed ? count + 1 : count,
      0
    )

    return (
      <section className="main">
        {this.renderToggleAll(completedCount)}
        <ul className="todo-list">
          {filteredTodos.map(todo =>
            <TodoItem key={todo.id} todo={todo} />
          )}
        </ul>
        {this.renderFooter(completedCount)}
      </section>
  }
}

Sunshine.Component looks up the current state in the given Sunshine.App instance to get initial application state, and runs that state through getState to get the initial component state when a component is mounted. Sunshine.Component also sets a listener so that whenever application state changes, the component's setState method is automatically called with the updated result of running application state through getState. This means that Sunshine pushes state changes into component state, instead of into component props.

An upside is that there is no special code needed to re-render components: Sunshine delegates to React's own state handling. But this does mean that a component that implements getState cannot have its internal state that is decoupled from application state. However, a Sunshine.Component subclass that does not implement getState can have internal state, and will still pass application context to child components. An application can get plenty of flexibility by mixing components that get state from application state, components that manage their own state, and components that are entirely stateless. Incorporating third-party components that use internal state also works fine.

Dispatching actions

Actions are emitted using a component method called emit:

class Header extends Sunshine.Component {
  handleSave(text) {
    if (text.length !== 0) {
      this.emit(new AddTodo(text))
    }
  }

  render() {
    return (
      <header className="header">
          <h1>todos</h1>
          <TodoTextInput newTodo
                         onSave={this.handleSave.bind(this)}
                         placeholder="What needs to be done?" />
      </header>
    )
  }
}

Once again, emit delegates to an app object that is passed to the root component as a prop, and is passed to child components via context. So this implementation is universal/isomorphic.

I mentioned above that using a class to represent an action type allows the type checker to identify reducers that need to be updated after a change is made to the parameters of an action. The same goes for dispatching actions: if someone added required parameters to AddTodo, or changed existing parameters, the type checker would inform the programmer that handleSave needs to be updated.

Assuming that the App component is also a subclass of Sunshine.Component, the whole thing would be kicked off like this:

var initialState = { todos: [] }
var app = new Sunshine.App(initialState, () => {
  React.render(
    <App app={app} />,
    document.getElementById('root')
  )
})

I did not want to irrevocably tie Sunshine to React; so I split Sunshine into a general-purpose module that can be used with any view layer, and another module containing the React-specific Sunshine.Component implementation.

Where Sunshine falls short

I love the combineReducers function in Redux. Redux has nice options for implementing reducers and stores as small components that are composed to form a larger app. In its current form Sunshine is pretty monolithic. I would like to figure out how to implement similar componentization capability in Sunshine.

@gaearon
Copy link
Contributor

gaearon commented Sep 23, 2015

How would you implement recording all actions into a log and later replaying them from a serialized log?

@hallettj
Copy link
Author

How would you implement recording all actions into a log and later replaying them from a serialized log?

Ah, I had not thought of that. Module-scoped values do make deserialization tricky. I have some ideas with different tradeoffs. I will have to get back to you when I have time to write them up.

@gaearon
Copy link
Contributor

gaearon commented Sep 23, 2015

This is the reason I think using discriminated unions for action types is better than making them classes.

@hallettj
Copy link
Author

That seems like a good point. If I come up with any relevant ideas around typechecking discriminated unions I will add to the discussion in #290. And I think I will continue to work on Sunshine with the understanding that it has a different set of goals.

Thank you for taking the time to read my comments and for responding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants