ember-resources
An implementation of Resources in Ember.JS without decorators.
Compatibility
- Ember.js v3.25+
- TypeScript v4.2+
NOTE: if you are also using ember-could-get-used-to-this, @use
is not compatible with
this library's LifecycleResource
, and useResource
does not work with ember-could-get-used-to-this' Resource
.
However, both libraries can still be used in the same project.
Installation
npm install ember-resources
yarn add ember-resources
ember install ember-resources
Example
import { useFunction } from 'ember-resources';
class MyClass {
data = useFunction(this, async () => {
let response = await fetch('...');
let json = await response.json();
return json;
}),
}
{{this.data.value}}
Usage
useResource
useResource
takes either a Resource
or LifecycleResource
and an args thunk.
import { useResource } from 'ember-resources';
class MyClass {
data = useResource(this, SomeResource, () => [arg list]);
}
When any tracked data in the args thunk is updated, the Resource will be updated as well
-
The this
is to keep track of destruction -- so when MyClass
is destroyed, all the resources attached to it can also be destroyed.
-
The resource will do nothing until it is accessed. Meaning, if you have a template that guards
access to the data, like:
{{#if this.isModalShowing}}
<Modal>{{this.data.someProperty}}</Modal>
{{/if}}
the Resource will not be instantiated until isModalShowing
is true.
-
For more info on Thunks, scroll to the bottom of the README
useTask
This is a utility wrapper like useResource
, but can be passed an ember-concurrency task
so that the ember-concurrency task can reactively be re-called whenever args change.
This largely eliminates the need to start concurrency tasks from the constructor, modifiers,
getters, etc.
A concurrency task accessed via useTask
is only invoked when accessed, and automatically updates
when it needs to.
import { useTask } from 'ember-resources';
class MyClass {
myData = useTask(this, this._myTask, () => [args, to, task])
@task
*_myTask(args, to, task) { }
}
Accessing this.myData
will represent the last TaskInstance
, so all the expected properties are available:
value
, isRunning
, isFinished
, etc.
See: the TaskInstance docs for more info.
NOTE: ember-resources
does not have a dependency on ember-concurrency
Making your own Resources with
Resource
Resources extending this base class have no lifecycle hooks to encourage data-derivation (via getters) and
generally simpler state-management than you'd otherwise see with the typical lifecycle-hook-aware Resource.
For example, this is how you'd handle initial setup, updates, and teardown with a Resource
import { Resource } from 'ember-resources';
import { registerDestructor } from '@ember/destroyable';
class MyResource extends Resource {
constructor(owner, args, previous) {
super(owner, args, previous);
if (!previous) {
} else {
}
registerDestructor(this, () => {
});
}
}
This works much like useFunction
, in that the previous instance is passed to the next instance and there
is no overall persisting instance of MyResource
as the args
update. This technique eliminates the need
to worry about if your methods, properties, and getters might conflict with the base class's API, which is
a common complaint among the anti-class folks.
Many times, however, you may not even need to worry about destruction, which is partially what makes opting
in to having a "destructor" so fun -- you get to choose how much lifecycle your Resource
has.
More info: @ember/destroyable
So why even have a class at all?
You may still want to manage state internal to your resource, such as if you were implementing a
"bulk selection" resource for use in tabular data. This hypothetical resource may track its own
partial / all / none selected state. If the args to this resource change, you get to decide if you
want to reset the state, or pass it on to the next instance of the selection resource.
import { Resource } from 'ember-resources';
class Selection extends Resource {
@tracked state = NONE;
constructor(owner, args, previous) {
super(owner, args, previous);
let { filterQueryString } = args.named;
if (previous && previous.args.named.filterQueryString !== filterQueryString) {
this.state = NONE;
}
}
@action selectAll() { this.state = ALL; }
@action deselectAll() { this.state = NONE; }
@action toggleItem(item) { }
}
usage of this Resource could look like this:
export default class MyComponent extends Component {
@service router;
get filter() {
return this.router.currentRouter.queryParams.filter;
}
records = useResource(this, EmberDataQuery, () => ({ filter: this.filter }));
selection = useResource(this, Selection, () => ({ filterQueryString: this.filter }))
}
For library authors, it may be a kindness to consumers of your library to wrap the useResource
call so that they only need to manage one import -- for example:
import { Selection } from './wherever/it/is.js';
export function useSelection(destroyable, thunk) {
return useResource(destroyable, Selection, thunk);
}
Another example of interacting with previous state may be a "load more" style data loader / pagination:
import { Resource } from 'ember-resources';
import { isDestroyed, isDestroying } from '@ember/destroyable';
class DataLoader extends Resource {
constructor(owner, args, previous) {
super(owner, args, previous);
this.results = previous?.results;
let { url, offset } = this.args.named;
fetch(`${url}?offset=${offset}`)
.then(response => response.json())
.then(results => {
if (isDestroyed(this) || isDestroying(this)) return;
this.results = this.results.concat(result);
});
}
}
consumption of the above resource:
import { useResource } from 'ember-resources';
class MyComponent extends Component {
@tracked offset = 0;
data = useResource(this, DataLoader, () => ({ url: '...', offset: this.offset }));
@action loadMore() { this.offset += 50; }
}
LifecycleResource
When possible, you'll want to favor Resource
over LifecycleResource
as Resource
is simpler.
They key differences are that the LifecycleResource
base class has 3 lifecycle hooks
setup
- called upon first access of the resourceupdate
- called when any tracked
used during setup
changesteardown
- called when the containing context is torn down
The main advantage to the LifecycleResource
is that the teardown hook is for "last teardown",
whereas with Resource
, if a destructor is registered in the destructor, there is no way to know
if that destruction is the final destruction.
An example of when you'd want to reach for the LifecycleResource
is when you're managing external long-lived
state that needs a final destruction call, such as with XState, which requires that the "State machine interpreter"
is stopped when you are discarding the parent context (such as a component).
An example
import { LifecycleResource } from 'ember-resources';
import { createMachine, interpret } from 'xstate';
const machine = createMachine();
class MyResource extends LifecycleResource {
@tracked state;
setup() {
this.interpreter = interpret(machine).onTransition(state => this.state = state);
}
update() {
this.interpreter.send('ARGS_UPDATED', this.args);
}
teardown() {
this.interpreter.stop();
}
}
Using this Resource is the exact same as Resource
import { useResource } from 'ember-resources';
class ContainingClass {
state = useResource(this, MyResource, () => [...])
}
There is however a semi-unintuitive technique you could use to continue to use Resource
for the final
teardown:
import { Resource } from 'ember-resources';
import { registerDestructor, unregisterDestructior } from '@ember/destroyable';
class MyResource extends Resource {
constructor(owner, args, previous) {
super(owner, args, previous);
registerDestructor(this, this.myFinalCleanup);
if (previous) {
unregisterDestructor(prev, prev.myFinalCleanup);
} else {
}
}
@action myFinalCleanup() { }
}
function
Resources
While functions can be "stateless", Resources don't provide much value unless
you can have state. function
Resources solve this by passing the previous
invocation's return value as an argument to the next time the function is called.
Example:
import { useFunction } from 'ember-resources';
class StarWarsInfo {
info = useFunction(this, async (state, ...args) => {
if (state) {
let { characters } = state;
return { characters };
}
let [ids] = args;
let response = await fetch(`/characters/${ids}`) ;
let characters = await response.json();
return { characters };
}, () => [this.ids ])
}
characters
would be accessed via this.info.value.characters
in the StarWarsInfo
class
While this example is a bit contrived, hopefully it demonstrates how the state
arg
works. During the first invocation, state
is falsey, allowing the rest of the
function to execute. The next time this.ids
changes, the function will be called
again, except state
will be the { characters }
value during the first invocation,
and the function will return the initial data.
This particular technique could be used to run any async function safely (as long
as the function doesn't interact with this
).
In this example, where the function is async
, the "value" of info.value
is undefined
until the
function completes.
To help prevent accidental async footguns, even if a function is synchronous, it is still ran
asynchronously, therefor, the thunk cannot be avoided.
import { useFunction } from 'ember-resources';
class MyClass {
@tracked num = 3;
info = useFunction(this, () => {
return this.num * 2;
});
}
this.info.value
will be undefined
, then 6
and will not change when num
changes.
Thunks
With the exception of the useResource
+ class
combination, all Thunks are optional.
The main caveat is that if your resources will not update without a thunk -- or consuming
tracked data within setup / initialization (which is done for you with useFunction
).
- The thunk is "just a function" that allows tracked data to be lazily consumed by the resource.
The args thunk accepts the following data shapes:
() => [an, array]
() => ({ hello: 'there' })
() => ({ named: {...}, positional: [...] })
An array
when an array is passed, inside the Resource, this.args.named
will be empty
and this.args.positional
will contain the result of the thunk.
for function resources, this is the only type of thunk allowed.
An object of named args
when an object is passed where the key named
is not present,
this.args.named
will contain the result of the thunk and this.args.positional
will be empty.
An object containing both named args and positional args
when an object is passed containing either keys: named
or positional
:
this.args.named
will be the value of the result of the thunk's named
propertythis.args.positional
will be the value of the result of the thunk's positional
property
This is the same shape of args used throughout Ember's Helpers, Modifiers, etc
Composition
These patterns are primarily unexplored so if you run in to any issues,
please open a bug report / issue.
Composing class-based resources is expected to "just work", as classes maintain their own state.
useFunction + useFunction
import Component from '@glimmer/component';
import { useFunction } from 'ember-resources';
class MyComponent extends Component {
rand = useFunction(this, () => {
return useFunction(this, () => Math.random());
});
}
Accessing the result of Math.random()
would be done via:
{{this.rand.value.value}}
Something to note about composing resources is that if arguments passed to the
outer resource change, the inner resources are discarded entirely.
For example, you'll need to manage the inner resource's cache invalidation yourself if you want
the inner resource's behavior to be reactive based on outer arguments:
Example data fetching composed functions
import Component from '@glimmer/component';
import { useFunction } from 'ember-resources';
class MyComponent extends Component {
@tracked id = 1;
@tracked storeName = 'blogs';
records = useFunction(this, (state, storeName) => {
let result: Array<string | undefined> = [];
if (state?.previous?.storeName === storeName) {
return state.previous.innerFunction;
}
let innerFunction = useFunction(this, (prev, id) => {
let newValue = `record:${storeName}-${id}`;
result = [...(prev || []), newValue];
return result;
},
() => [this.id]
);
return new Proxy(innerFunction, {
get(target, key, receiver) {
if (key === 'previous') {
return {
innerFunction,
storeName,
};
}
return Reflect.get(target, key, receiver);
},
});
},
() => [this.storeName]
);
}
{{this.records.value.value}} -- an array of "records"
Public Types
import type { ArgsWrapper, Named, Positional } from 'ember-resources';
where:
ArgsWrapper
interface ArgsWrapper {
positional?: unknown[];
named?: Record<string, unknown>;
}
this is a utility interface that represents all of the args used throughout
Ember.
Example
class MyResource extends LifecycleResource {
constructor(owner: unknown, args: ArgsWrapper) {
super(owner, args);
}
}
Shorthand for positional only
export interface Positional<T extends Array<unknown>> {
positional: T;
}
Example:
class MyResource extends LifecycleResource<Positional<[number]>> {
}
Shorthand for named only
export interface Named<T extends Record<string, unknown>> {
named: T;
}
Example:
class MyResource extends LifecycleResource<Named<{ bananas: number }>> {
}
These shorthands are 3 characters sharter than using the named:
or
positional:
keys that would be required if not using these shorthands...
Testing
If your resources are consumed by components, you'll want to continue to
test using rendering tests, as things should "just work" with those style of
tests.
Where things get interesting is when you want to unit test your resources.
There are two approaches:
new
the resource directly
import { LifecycleResource } from 'ember-resources';
test('my test', function(assert) {
class MyResource extends LifecycleResource {
}
let instance = new MyResource(this.owner, { });
})
The caveat here is that the setup
and update
functions will have to
be called manually, because we aren't using useResource
, which wraps the
Ember-builtin invokeHelper
, which takes care of reactivity for us. As a
consequence, any changes to the args wrapper will not cause updates to
the resource instance.
For the Resource
base class, there is a static helper method which helps simulate
the update
behavior.
import { Resource } from 'ember-resources';
test ('my test', function (assert) {
class MyResource extends Resource {
}
let instance = new MyResource(this.owner, { });
let nextInstance = MyResource.next(instance, { });
});
Resource.next
, however, does not destroy the instance. For that, you'll want to use
destroy
from @ember/destroyable
.
import { destroy } from '@ember/destroyable';
destroy(instance);
Create a wrapper context for reactive consumption
If, instead of creating MyResource
directly, like in the example above,
it is wrapped in a test class and utilizes useResource
:
import { useResource } from 'ember-resources';
class TestContext {
data = useResource(this, MyResource, () => { ... })
}
changes to args will trigger calls to setup
and update
.
NOTE: like with all reactivity testing in JS, it's important to
await settled()
after a change to a reactive property so that you allow
time for the framework to propagate changes to all the reactive bits.
Example:
import { LifecycleResource, useResource } from 'ember-resources';
test('my test', async function (assert) {
class Doubler extends LifecycleResource<{ positional: [number] }> {
get num() {
return this.args.positional[0] * 2;
}
}
class Test {
@tracked count = 0;
data = useResource(this, Doubler, () => [this.count]);
}
let foo = new Test();
assert.equal(foo.data.num, 0);
foo.count = 3;
await settled();
assert.equal(foo.data.num, 6);
Related addons
List of addons that use and wrap ember-resources
to provide more specific functionality:
- ember-data-resources - resources for reactive data fetching with ember-data
- ember-array-map-resource - provides a useArrayMap function which returns a resource that reactively maps data per-element, so that when the overall collection is dirtied, only the changed/new/removed elements affect the mapped collection
Contributing
See the Contributing guide for details.
License
This project is licensed under the MIT License.
Thanks
This library wouldn't be possible without the work of:
So much appreciate for the work both you have put in to Resources <3