Skip to content

Instantly share code, notes, and snippets.

@brigand
Last active January 5, 2017 13:01
Show Gist options
  • Save brigand/12795f3a1b8b75d6b15e23cf89e72e51 to your computer and use it in GitHub Desktop.
Save brigand/12795f3a1b8b75d6b15e23cf89e72e51 to your computer and use it in GitHub Desktop.
react-secretary: an idea for a react helper library

You set up the secretary like this:

import {hire} from 'react-secretary';
import secConfig from '../utils/secConfig';
class Foo extends React.Compoennt {
  constructor(props) {
    super(props);
    this.sec = hire(this, secConfig);
  }
}

Then you can use any of the methods to build your component. We'll go over them in no particular order. The config for each feature will be described in the relevant section.

http

A http client is included that allows you to make http requests and have them safely cleaned up if the component unmounts, either by aborting the request or ignoring the response.

The config for this looks like this:

export default {
  http: {
    base: '/api/',
    abortGetOnUnmount: false,
  },
};

First we'll build the request.

const req = this.sec
  .http()
  .abortOnUnmount()
  .get('kittens')
  .query({fluffy: true})
  .send();

Then we can do a few things. The most basic one is to use it as a promise.

this.setState({loading: true, error: null});
req
.then((body) => {
  this.setState({data: body, loading: false, error: null});
})
.catch((errBody) => {
  this.setState({data: null, loading: false, error: errBody});
});

That approach gives us the most control. For more normal cases, you may want to bind the request to state directly, optionally specifying the names of each state key. When path is null it's spread into top level state. We can specify a path like kittens.fluffy which results in this.state.kittens.fluffy.data.

req.saveToState({data: 'data', loading: 'loading', error: 'error', path: null});

By default, on unmount we ensure the .then and .catch never run, and saveToState does nothing. For some requests you may choose to abort the request saving some network usage. .abortOnUnmount() does this.

The http.abortGetOnUnmount config option makes this the default behavior. It can be disabled on a given request with .noAbortOnUnmount().

log

TODO, maybe something with displaying a global alert with customized a component for displaying errors to users. Should be callable from outside of components. Possibly import {log} from 'react-secretary';.

Support optional automatic logging of all instance methods. Saves the user from adding a bunch of console.log()s.

Allow disabling all or some logs from the current component in one place, and global config to set log level.

Global and local config to send some logs to a server endpoint.

log.toJSX pretty prints some data so that it can be rendered directly in the render method. Good for debugging ajax and similar. Doesn't end up cluttered with other console logs.

isMounted

React removed this in es6 classes, we we're adding it back.

this.sec.isMounted() // true or false

onPropChange

Commonly you want to perform some action when a prop changes. The main instances of this are performing an ajax request (either directly or by dispatching a redux action). There are different definition of 'change'. By default, it compares with ===. There are no global config options for this command.

this.sec.onPropChange('id', (props) => {
  props.fetchStuff(props.id);
}, {
  // default config options
  // this argument is optional
  onMount: true,
  equality: (a, b) => a === b, 
});

Further, if you return a sec.http request object, it will automatically abort or ignore the request if the prop changes again. If you don't want this, just don't return it.

this.sec.onPropChange('id', (props) => {
  return this.sec
    .http()
    .get('kittens/' + props.id)
    .send()
    .saveToState({path: 'kittens'});
});

perf

A wrapper around react perf tools? TODO

future

An implementation of react-future features.

class Kittens extends React.Component {
  constructor(props) {
    super(props);
    this.sec = hire(this, config);
    this.sec.future.enable();
  }
  render(props, state) {
  }
  componentDidMount(props, state) {
    return {newStateKey: value};
  }
  // etc.
}

events

Adds event listeners to window or specific elements. Listeners are cleaned up on unmount. In the future a version of react's synthetic events may be added.

The config determines if passive events should be used for certain listeners, if available. Passive events drastically improve performance on mobile, and moderately on desktop.

export default {
  events: {
    passive: true, // default
    target: window, // default
  },
};

Listeners can be added with a simple descriptor. This api may need to be simplified, or require less code.

this.sec.events.listen({
  target: window, // default, can be an array
  passive: true, // default
  event: 'scroll', // can be an array
  listener: (event, eventType) => {
    if (eventType === 'scroll') {
      // ...
    }
  },
});

render

The render namespace has various utilities that affect render.

render.loading

This allows you to render a loading indicator that's consistent across the app. You define this in constructor and it will replace render's output with the loading indicator when a condition is met.

The config allows you to specify defaults. If you enable transition but don't override transitionName we'll inject a basic css transition <style> into the page html.

export default {
  render: {
    loading: {
      when: (state, props) => props.loading || state.loading,
      render: (state, props) => <div />, // default
      transition: false, // use CSSTransitionGroup or not
      transitionName: 'react-secretary-loading-transition',
    },
  }
};

The options argument is the same structure as the config object.

this.sec.render.loading({
  when: (state, props) => state.loading,
  render: (state, props) => <LoadingIndicator />,
  // etc.
});

render.combineProps

Often in custom components you want to merge some of your props into the root element. These props are className and style, a callback ref passed from the parent, and event handlers.

export default {
  render: {
    combineProps: {
      className: true,
      style: true,
      rootRef: true,
      allEventHandlers: false, // enable to pass onClick, etc.
      allDomProps: false, // enable to pass tabIndex, etc.
      
      // pass arrays of prop names
      // these overried the above settings
      whitelist: [],
      blacklist: [],
    },
  },
};

Call the function in constructor. The global config is the same as the component config.

constructor(props) {
  super(props);
  this.sec = hire(this, config);
  // this allows style, className, rootRef, and onClick
  // (assuming default config)
  this.sec.render.combineProps({
    whitelist: ['onClick']
  });
}

render.bind

This wraps render to support two way data binding. It does this in a react friendly way, including allowing data to be transferred by callback props instead of setting local state, and works with custom components.

The global config is merged deeply into the local config. You'll want to define most of the custom component config globally, and the rest locally.

Since this could be a library itself, you can import it directly and avoid the extra bundle size.

import renderBind from 'react-secretary/lib/renderBind';

renderBind(this, config);
this.sec.render.bind({
  components: [
    // these are the defaults for custom components
    {type: Input, callback: 'onChange', value: 'value'},
  },
  anyChangeHandler: (newValue, oldValue, path) => {},
  changeHandlers: {
    'state.search': (newValue, oldValue, path) => {},
  },
  
  // allows use of callback instead of local state
  // this will make a copy of this.props.data, update it,
  // and pass it to this.props.onChange
  callbacks: [{
    // could be a custom key for sec-bind-callback to allow
    // multiple callbacks
    key: null,
    callback: 'props.onChange',
    value: 'props.data',
  }],
});

In your render you use special props for binding fields.

render() {
  return (
    <div>
      <Input sec-bind="state.search.text" placeholder="Search..." />
      <button sec-bind-toggle={{event: 'click', path: 'state.search.fluffy'}}>
        toggle fluffy
      </button>
      
      <Input
        // could be sec-bind-callback="some-key" which matches 'key' in callbacks[i].key
        sec-bind-callback
        // this.props.data.text
        sec-bind="text"
      />
    <div>
  );
}

store

Most applications should use redux, but sometimes it's overkill for simple projects. A global data store is provided. It's made up of buckets which contain key/value pairs. Bucket names and keys can be any type but are compared by reference equality. Items can be accessed by any component.

// bind the store to state
this.sec.store({
  bucket: 'kittens', // required
  copyToState: 'kittens',
});

// update the key
this.state.kittens.set('oliver', {name: 'oliver'});

// handle the bucket changing yourself
this.sec.store({
  bucket: 'kittens',
  onChange: (kittensMap, prevKittenMap) => {
    if (kittensMap.get('oliver') !== prevKittenMap.get('oliver')) {
      this.setState({kitten: kittensMap.get('oliver')});
    }
  },
});

// bind to a specific key
// only triggers when it changes
// unsubscribe is called for you when the component unmounts
const unsubscribe = this.sec.store({
  bucket: 'kittens',
  keys: [
    {key: 'oliver', onChange: (kitten) => this.setState({kitten})}
  ]
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment