DEV Community

Cover image for Introduction of Fleur - It's a new Flux framework
Hanakla
Hanakla

Posted on • Updated on

Introduction of Fleur - It's a new Flux framework

It's self-translated article. Source: https://inside.pixiv.blog/ragg/7050

Hello everyone! I'm Hanakla who is a front-end engineer at VRoid Hub at pixiv πŸ™Œ

This time, I will introduce the design and usage of β€œFleur”(npmjs:@fluer/fleur), a flux framework I have developed for about a year!

table of contents

  • What kind of framework?
  • Usage
  • Question
  • Finally

What kind of framework?

It referred to Fluxible, which is adopted in pixiv Sketch , I made a full scratch with TypeScript, focusing on two points: "Writing ease" and "Adopting modern features". (I think that Fluxible is the most "complete" framework I know ♨)

Although it has not been adopted in large-scale production of Fleur, it has been developed by adjusting API and performance for one year in a performance-sensitive Web-based VFX application product called "Delir".

Feature

  • Server-side rendering (SSR) ready
  • React Hooks compatible (useFleurContext(), useStore() etc.)
  • Comfortable to writing the code
  • Supports asynchronous processing as standard
  • immer.js Built-in Store
  • SSR compatible router
    • Design considering API fetch Supports delayed component loading by Dynamic import (no code conversion required)
  • Performance approaching redux + react-redux
    • Some benchmarks may be faster than react-redux
  • Redux DevTools compatibility
    • Not compatible with Skip action

In search of the easier to write and robust Flux framework.

First let's look at the definition of Action / Operations / Store in Fleur.

// Action
import { actions, action } from '@fleur/fleur'

export const CounterActions = actions('CounterAction', {
    increase: action<{ amount: number }>(),
    decrease: action<{ amount: number }>(),
})

// Operations
import { operations } from '@fleur/fleur'
import { CounterActions } from './actions.ts'

export const CounterOps = operations({
  async increase({ dispatch }, amount: number) {
    dispatch(CounterActions.increase, { amount })
  },

  async decrease({ dispatch }, amount: number) {
    dispatch(CounterActions.decrease, { amount })
  }
})

// Store
import { listen, Store } from '@fleur/fleur'
import { CounterActions } from './actions.ts'

type State = {
  count: number
}


export class CountStore extends Store<State> {
    public state: State = { count: 0 }

    private handleIncrease = listen(CounterActions.increase, (payload) => {
        this.updateWith(draft => draft.count += payload.amount)
    })

    private handleDecrease = listen(CounterActions.decrease, (payload) => {
        this.updateWith(draft => draft.count -= payload.amount)
    })

    public get count() {
        return this.state.count
    }
}

Since the definition of Action itself and its type definition are integrated, the process of not having scattered descriptions and having to write multiple code for defining one Action is reduced. The definition of Action is called an Action Identifier inside Fleur.

Operations supports asynchronous processing from the beginning. Therefore, there is no need to choose thunk or saga.

Store is characterized by the listen() and .updateWith(). listen() infers the argument type of the action handler based on the Action Identifier. In Fluxible, the action type and the name of its handler method were specified in the property named handlers, but as a result of considering code that is simpler and type inference-friendly, it is such an API. (The function reducerStore() can be created with reference to mizchi/hard-reducer.)

Also wanted a router designed based on the real scene.

Fleur's standard router @fleur/route-store-dom is designed based on the production code of SSR services such as pixiv Sketch and VRoid Hub.

To the extent that I have investigated, the router libraries that currently exist around React seem to have the problem that "the best practices for API requests do not exist" in general. Even in the VRoid Hub using the react-router, its implementation is quite dirty. In that respect, Fluxible's fluxible-router had an API close to the ideal solution, so Fleur followed it.

The fact that having a process that can be out of sync in the React world, which is supposed to be synchronous processing, is not yet compatible at present, and I think that even if React Suspense is included, it may not be possible for SSR to have a painful state for a while. πŸ˜” πŸ˜” (There is also a theory that SSR will not be required by Googlebot engine update)

As a feature fluxible-router doesn't have, support for dynamic import of components is implemented in Fleur. In existing router libraries, it is react-loadable to work hard with react-loadable , or use a large-scale method such as separating SSR and CSR code using transform by babel etc. However, in Fleur, routing is performed outside the React life cycle. It is possible to cope with dynamic import without any code transform.

import { createRouteStore } from '@fleur/route-store-dom'

export const Router = createRouteStore({
  userShow: {
    path: '/user/:id',
    // Can be API fetching before page load.
    action: ({ executeOperation }, route) =>
      Promise.all([
        executeOperation(fetchUser, { id: route.param.id }),
      ]),
    // And support dynamic import.
    handler: () => import('./routes/User'),
  },
})

Usage

Fleur requires at least four elements: Actions, Stores, Operations, and View to create an application. It feels like a to many, but I think that if you write a medium-scale app, you can pay relatively well. (It looks like Redux's Action Creator is separated into Actions and Operations.)

The recommended directory structure looks like this:

app/ 
  β”” components/
  β”” domains/
    β”” Entity
    β”” actions.ts
    β”” operations.ts
    β”” store.ts 

From here on, we will add sample code in order to explain the flow.

Actions

he reality of Action in Fleur is just a type store, a function that does nothing. action() function accepts the payload type as an argument and returns a function that can not actually be called.

Within Fleur, the function instance generated at this time is called the Action Identifier, and at dispatch time it fires the Store listener with strict equivalence to the function instance. (It is a JavaScript trick)

// Actions definition (actions.ts)
import { action } from '@fleur/fleur'

export const CounterActions = actions('CounterAction', {
    increase: action<{ amount: number }>(),
    decrease: action<{ amount: number }>(),
})

Store

In Store, use the listen() function to specify which action to handle.

// store.ts
import { listen, Store } from '@fleur/fleur'
import { CounterActions } from './actions.ts'

type State = {
  count: number
}

export class CountStore extends Store<State> {
    public state: State = { count: 0 }

    private handleIncrease = listen(CounterActions.increase, (payload) => {
        this.updateWith(draft => draft.count += payload.amount)
    })

    private handleDecrease = listen(CounterActions.decrease, (payload) => {
        this.updateWith(draft => draft.count -= payload.amount)
    })

    public get count() {
        return this.state.count
    }
}

There are two features of Store.

  1. The above-mentioned Action Identifier infers the payload type.
  2. this.updateWith(). Since this method is actually a wrapper for immer.js, updating the state is immutable even if you mutate the draft. The return value is ignored by Fleur, so there is no need to add braces like draft => { ... }.

.updateWith() updates the status and notifies the View side of the change, but this update notification is buffered using requestAnimationFrame (*1, *2), so multiple times within a single handler .updateWith() will also minimize blocking to the UI.

Refer to the following code for details on how the listen function associates an action with a handler.

Contents of listen(): fleur/src/Store.ts#L12
Linking to Action: fleur/src/AppContext.ts#L116

*1 Even if the status of multiple stores changes in one dispatch, they will be grouped and buffered.

Implementation of Store#updateWith: fleur/src/Store.ts#L47
Change Notification Buffering: fleur/src/StoreContext.ts#L12

*2 SSR does not perform buffering but processes synchronously.

Operations

This is a name unique in Fleur, but it is a layer that causes communication with the API and side effects. It corresponds to redux-thunk etc. Entity normalization is also easy to handle if it is done in this layer by cutting normalization processing into functions or using normalizr.

// operations.ts
import { operations } from '@fleur/fleur'
import { CounterActions } from './actions.ts'

export const CounterOps = operations({
  async increase(context, amount: number) {
    context.dispatch(CounterActions.increase, { amount })
  },

  async decrease(context, amount: number) {
    context.dispatch(CounterActions.decrease, { amount })
  }
})

{dispatch, getStore} is passed in the context of each method, and it is also possible to refer to the state of the Store from within the operation. For example, it is useful in the case where "The authentication information of the user in the Store is required when making API request". operations() function itself is just for type inference and does nothing in particular.

Operations support Promise as standard, so you can use async / await sparingly.

View

Next, connect these to View.

// App.tsx
import React, { useCallback } from 'react'
import { useFleurContext, useStore } from '@fleur/fleur-react'
import { CounterOps } from './operations'
import { CountStore } from './store'

export const App = () => {
  const { executeOperation } = useFleurContext()

  const { count } = useStore([CountStore], getStore => ({
    count: getStore(CountStore).getCount(),
  }))

  const handleCountClick = useCallback(() => {
    executeOperation(CounterOps.increase, 10)
  }, [])

  return <div onClick={handleCountClick}>{count}</div>
}

View uses useStore() and useFleurContext().

useStore() the Store class to be monitored in the first argument to useStore and the function to retrieve the state from the Store in the second argument, component update will be performed when there is a Store update. (This one is similar to react-redux's connect())

useFleurContext() is hooks that returns { getStore, executeOperation }. When executing some operation depending on the user action, pass the operation you want to execute and its arguments to executeOperation. getStore is useful for scenes that "do not affect the appearance but want the state of the Store in the callback". (It should basically be done within Operations)

Finally, writing the launch part of the application completes the entire flow!

// client-entry.tsx
import Fleur from '@fleur/fleur'
import { FleurContext } from '@fleur/fleur-react'
import React from 'react'
import ReactDOM from 'react-dom'

import AppRoot from './App'
import { CountStore } from './store'

const app = new Fleur({ 
  stores: [ CountStore ] 
})

const context = app.createContext()

window.addEventListener('DOMContentLoaded', () => {
  const root = document.querySelector('#root')

  ReactDOM.render(
    <FleurContext value={context}>
      <AppRoot />
    </FleurContext>
  , root)
})

Question

Router?

For a sample Router, see the README for @fleur/route-store-dom.

What is the sample of SSR?
I will omit it in this article, but please take a look at this sample.

Does memory leak occur when operating in a large scale environment? It is hoped that you can give us feedback as there are not enough tests for that.

Performance, what about numerically?

It feels like the benchmark of Fleur vs Floible vs react-redux on Travis CI.

As a result of optimizing the performance, it was overwhelmingly faster than Fluxible, and the performance was so thin or overtaking against react-redux.

There are three main optimizations that Fleur is doing internally:

Batch notification of updateWith () calls in a short time in the Store
Batch notification of multiple dispatch in a short time across the Store
Component-side bounce of change notifications from multiple Stores listening
Anyway, I made it to optimize in the feeling of "It is heavy when Re-rendering with React is raised!!! Another is nothing!!!"

Finally

I've just released a major version, and I think that there are still improvements since there is little knowledge about React Suspense and GraphQL, but I think that I can now cope with most problems.

If you feel like "Why did the 21st century become reluctant, but we are writing routings that we didn't have to write in the 20th century anymore?" You can feel good πŸ”«β˜Ί

Thanks for reading! cheers~~

Top comments (0)