Skip to content

Latest commit

 

History

History
272 lines (205 loc) · 15.8 KB

advanced_topics.md

File metadata and controls

272 lines (205 loc) · 15.8 KB

Contents

  1. Thoughts on the PENDING Resource
  2. Implicit dependent resources
  3. Unfetched Resources
  4. Loading Overlays
  5. Recaching newly-saved models

Thoughts on the PENDING Resource

Using dependsOn in simple cases like the one highlighted in the README is pretty straightforward and very powerful. But PENDING resources bring additional complexities to your resource logic, some of which are enumerated here:

  1. PENDING critical resources don’t contribute to isLoading/hasErrored states, but will keep your component from reaching a hasLoaded state. Semantically, this makes sense, because hasLoaded should only be true when all critical resources have loaded, regardless of when a resource’s request is made.

  2. When a PENDING resource request is not in flight, its model prop will still be the empty model instance whose properties are frozen (as happens when the resource is in the other three possible loading states). This is to more predictably handle our resources in our components. We don’t need to be defensive with syntax like:

    todosCollection && todosCollection.toJSON(); // unnecessary

    Furthermore, if you've defined additional instance methods on your model, they will be present without being defensive:

    todosCollection.myInstanceMethod(); // guaranteed not to error regardless of loading state
  3. When a previously-PENDING but currently LOADED resource has its dependent prop removed, it goes back to a PENDING state (recall that if the dependent prop is changed, it gets put back into a LOADING state while the new resource is fetched). This puts us in an interesting state:

    1. hasInitiallyLoaded will remain true, as expected. But the PENDING resource’s prop—assuming the resource’s cacheFields list includes the dependent prop—will now return to the empty model/collection, so any child component that may remain in place after hasInitiallyLoaded may need to keep that in mind (if shouldComponentUpdate returns false when the new resource fetches, then this shouldn’t matter—see the next point).

    2. Recall that we can provide the dependent prop in one of two ways:

      1. We can include it in another resource’s provides property, in which case the dependent prop gets set as state within resourcerer.
        1. We can also set state in our client component and pass it to the executor function, but that requires an extra render cycle.
      2. We can modify the url in the component’s componentDidUpdate/useEffect (either url path or query parameter), which will filter the prop down.

      When we provide using method (a), the dependent prop can be changed but not removed. When we provide using method (b), the dependent prop can be changed or removed.

      As an example of how we might be able to remove a dependent prop via method (b), consider someone navigating to a /todos url that auto-navigates to the first todo item and displays its details. The todoItem details resource depends on a todoId prop, which it gets in a useEffect via changing the url once the todos resource loads. So now we’re at /todos/todo1234. But if the user clicks the back button, we’ll be back at /todos with a cached todos resource and PENDING todoItem resource, and all three loading states set to false. (Yes, this is a bit contrived because you should actually replace the history entry in this case, but hopefully it helps to illuminate the issue.)

      So if we remove the dependent prop, we enter a state where isLoading, hasLoaded, and hasErrored are all false. And since we have to wait for a useEffect to re-auto-update the url with the dependent prop, a lifecycle passes with this state, and there’s really nothing we can do about it.

      And again—hasInitiallyLoaded is still true and the todoItemModel model prop is empty, which can cause layout issues if you use, for example, an overlaid loader over a previously-rendered component. For this reason, if using classes/withResources, such a previously-rendered component should use nextProps.hasLoaded instead of !nextProps.isLoading in its shouldComponentUpdate:

      // overlay-wrapped component, where a loader will show over previously-rendered children,
      // which we want to then not update. but this component is also a child of a `withResources`
      // component that has a dependent resource
      shouldComponentUpdate(nextProps) {
        // using `return !nextProps.isLoading;` would update the component in the above
        // scenario, even though `nextProps.myDependentModel` would be empty
        return nextProps.hasLoaded;
      }
      
      // this assumes that the parent is handling the `hasErrored` state. if it is not, then
      // you may need to instead use:
      shouldComponentUpdate(nextProps) {
        return !(nextProps.isLoading || areAnyPending(nextProps.myDependentLoadingState));
      }

      If using useResources, we'll want to do the equivalent in our memo:

      const MemoizedComponent = memo(<Component />, (prevProps, nextProps) => !nextProps.hasLoaded);
      
      function Parent() {
        return (
          <div>
            {isLoading ? <OverlayLoader /> : null}
            {hasInitiallyLoaded ? <MemoizedComponent /> : null}
          </div>
        );
      }
    3. In the case that the model's cacheFields does not include the dependent prop (ie, the prop is used solely for triggering the resource request and doesn't factor into the request data), the model will still exist in the cache when the dependent prop is removed. In this case, the loading state is still returned to PENDING, but the existing model will also be present in the return value.

Implicit dependent resources

Another way to effectively have a dependent resource is to use a conditional in your getResources method:

const getResources = (ResourceKeys, props) => ({
  [ResourceKeys.TODOS]: {},
  ...(props.todoId ? {[ResourceKeys.TODO_ITEM]: {data: {id: props.todoId}} : {})
});

In general, using dependsOn is much more preferable, both in terms of semantics and functionality. The key difference here is that the dependent resource does not get put into a PENDING state, and hasLoaded depends on an unpredictable number of resources—for example, in the above scenario, what happens if props.todoId never arrives? Using dependsOn, hasLoaded would not be true, but using the conditional, it would be. This means that with the conditional, you can’t freely make assumptions behind the hasLoaded flag:

{hasLoaded ? (
  // with the conditional, you don't know which resources are available. with
  // `dependsOn`, you do
) : null}

That doesn’t mean that the conditional can’t be useful—it’s just that its use should be relegated to components that have two discrete forms—one in which the dependent prop is always present, and one in which the dependent prop is never present. If you’re unsure whether a prop might exist, notably because it comes from a providing resource, you should use dependsOn. A good example of when to use a conditional is in this fake component that sometimes fetches a user model and sometimes fetches an order model depending on the presence of an orderId prop:

const getResources = (ResourceKeys, {userId, orderId}) => ({
  ...userId ? {[ResourceKeys.USER]: {options: {userId}} : {},
  ...!userId && orderId ? {
    [ResourceKeys.ORDER]: {
      noncritical: true,
      data: {id: orderId}
    }
  } : {}
});

In this case, when the component is used as an order component (denoted by the presence of the orderId prop), we fetch the order resource. Otherwise, we don’t.

For all other uses of dependent resources, we should use dependsOn.

Unfetched Resources

You may find, at some point in your application, that you have a PUT endpoint for a resource but no GET; the 'read portion' of the resource is received as part of some parent resource. For example, imagine you have an accounts resource at /accounts/{account_id}, whose response has a config property with some account configuration settings. To update the configuration, you make a PUT to /accounts/{account_id}/config. But reading comes from the parent resource. resourcerer supports this via a providesModels static property on the model:

// resources_conifg.js
import {ResourceKeys, UnfetchedResources} from 'resourcerer';

ResourceKeys.add({
  ACCOUNT: 'account',
  ACCOUNT_CONFIG: 'accountConfig'
});

// add the ACCOUNT_CONFIG key to our set of UnfetchedResources
UnfetchedResources.add(ResourceKeys.ACCOUNT_CONFIG);


// account_model.js
export default class AccountModel extends Model {
  url() {
    return `/accounts/${this.accountId}`;
  }

  static cacheFields = ['accountId']
  
  static providesModels = (accountModel, ResourceKeys) => [{
    data: accountModel.get('config'),
    modelKey: ResourceKeys.ACCOUNT_CONFIG,
    options: {accountModel}
  }]
})

The providesModels property is a function that takes the parent model and the ResourceKeys as arguments and returns an array of resource configs. The resource configs have the same schema as the those used in our general executor functions. What this tells resourcerer to do is, after the parent model returns, instantiate the child model(s) and place them into the ModelCache. In other components, you can then access the model you know to exist in a withResources or useResources declaration:

// child_component.jsx, rendered only after the account model is known to return
@withResources((ResourceKeys, props) => ({[ResourceKeys.ACCOUNT_CONFIG]: {}}))
class ChildComponent extends React.Component {
  // component has this.props.accountConfigModel from the cache!
  onClickSomething() {
    // and now you can update the config directly :)
    this.props.accountConfigModel.save();
  }
}

In general, this should be used in cases where you can ascertain that the parent model has returned before trying to access the child model. However, if by chance it has not, and the child is not found in the cache, resourcerer will still not attempt to fetch it, because it is listed within the UnfetchedResources set. In that case, the model will get instantiated with no seed data and passed as a prop.

Also, note that the modelKey property is required here instead of optionally being inferred from the resource config's object property, as is the case in our general useResources/withResources declarations. This is because here, in contrast, the models are simply placed in the cache and not actually used as props for any component, so they don't need to be named. Accordingly, resource configs are also returned as a list here instead of an object.

The resource config objects within providesModels have the same schema, as mentioned, as normal. But they also accept an additional optional property, shouldCache, which is a function that takes the parent model and the resource config as an argument. If the function exists and returns false, the model will not get instantiated nor placed in the cache:

// account_model.js
export default class AccountModel extends Model {
  // ...
  static cacheFields: ['accountId']
  
  static providesModels = (accountModel, ResourceKeys) => [{
    data: accountModel.get('config'),
    modelKey: ResourceKeys.ACCOUNT_CONFIG,
    options: {accountModel},
    // if this returns false, account config won't get instantiated and placed in the ModelCache
    shouldCache: (accountModel, config) => accountModel.get('state') === 'ACTIVE'
  }]
})

Loading Overlays

You probably want to show loaders when you are moving between one model and the next. Because your resources are held as state, both exist in this intermediate state. Both the hook and the HOC provide a hasInitiallyLoaded prop that is useful here. Here's an example of what it might look like (shown over one of Sift's Insights Charts):

loading_overlay

Use it like:

import {useResources} from 'resourcerer';

const getResources = ({TODOS}, props) => ({[TODOS]: {}});

export default function UserTodos(props) {
  const {isLoading, hasInitiallyLoaded, todosCollection} = useResources(getResources, props);
    
  return (
    <div className='MyComponent'>
      // it's up to you to make this an OverlayLoader or InlineLoader, if you so choose.
      {isLoading ? <Loader /> : null}
      // this will render once first loaded and remain rendered with a previous
      // model even while in a loading state. when a new resource request returns,
      // this will render with the updated model and the loader will be removed.
      {hasInitiallyLoaded ? (
        <ul>
          {todosCollection.map((todoModel) => (
            <li key={todoModel.id}>{todoModel.get('name')}</li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

Recaching newly-saved models

A common pattern when creating a new instance of a resource is to keep it in state until the user decides to explicitly save it. For example, when setting up a new TODO, we might fill out a form with its name, all kept in state, at a deep-linkable RESTful url that might be /todos/new. After the user saves the TODO, we get a server-provided id property and we might navigate to /todos/12345, where 12345 is the new id. This presents an inconvenience: at /new we want to read from state, but at /{id} we want to read from the saved model (this is compounded if the UI allows the user to edit in-place).

To accomodate this scenario, resourcerer provides a means of re-caching a model instance the first time it receives an id. Thus you can use it with fetch: false like this:

import {useResources} from 'resourcerer';

const getResources = ({TODO}, props) => ({
  [TODO]: {
    data: {id: props.id},
    fetch: !!props.id
  }
});

export default function UserTodo(props) {
  const {todoModel} = useResources(getResources, props),
        onChange = (evt) => todoModel.set('name', evt.target.value),
        onSubmit = todoModel.save()
          .then(([model]) => !props.id ? navigate(`/todos/${model.id}`) : null)
          .catch(() => notify('An error occurred');

  return (
    <form onSubmit={onSubmit}>
      <label>
        TODO:
        <input name='name' onChange={onChange} value={todoModel.get('name')} />
      </label>
      <button>Save</button>
    </form>
  );
}

What's so nice about this example is that there's no balancing between React state and model state; in either case you read from the model requested from resourcerer. When you load /todos/new, nothing is fetched and the model is created first client-side; when you load /todos/{todosId}, the todos resource is first fetched. Both cases, however, are treated identically. And because of resourcerer's recaching, when you save for the first time and navigate from /todos/new to /todos/{todosId}, the model is taken from its 'new' cache key and placed in its 'id' cache key, obviating the need to re-request the resource and moving seamlessly from one to the other.

NOTE: this is only for models requested individually and not as part of a larger collection. When adding a new model to a collection that is fetched by resourcerer, you can accomplish the above without recaching.