0.15.0 • Published 3 years ago

reason-relay v0.15.0

Weekly downloads
618
License
MIT
Repository
github
Last release
3 years ago

reason-relay

Bindings and a compiler plugin for using Relay with ReasonML.

REQUIRES BuckleScript 6, which is currently in beta

NOTE: This is alpha-grade software and various aspects of the API is likely to change in the near future. We're also missing support for a few core Relay features in waiting for the official Relay hooks based APIs. It is not recommended to use this for production apps yet, but you're welcome and encouraged to try it out and post your feedback.

Please refer to ARCHITECTURE.md for a more thorough overview of the different parts and the reasoning behind them.

Getting started

Requires BuckleScript 6 (currently in beta), Relay == 5.0.0 and React >= 16.8.1

# Add reason-relay to the project
yarn add reason-relay

# You also need to make sure you have Relay and relay-hooks installed. NOTE: Babel and the Relay babel plugin is not needed if you only use ReasonRelay.

yarn add relay-hooks relay-runtime@5.0.0 relay-compiler@5.0.0 react-relay@5.0.0 

Add ReasonRelay bindings + PPX to bsconfig.json.

...
"ppx-flags": ["reason-relay/ppx"],
"bs-dependencies": ["reason-react", "reason-relay"],
...

As of now, the Relay compiler does not natively support what we need to make it work for emitting Reason types. Therefore, we ship a patched compiler that you can use. It works the same way as the Relay compiler, except you don't need to provide --language and you must provide --schema via the CLI (not only via relay.config.js or similar).

Run the script via package.json like this:

"scripts": {
  "relay": "reason-relay-compiler --src ./src --schema ./path/to/schema.graphql --artifactDirectory ./src/__generated__"
}

yarn relay

Usage

Check out the examples folder for a complete example of setup and usage of most features.

In general, the bindings closely matches how Relay itself works. However, there's a few opinionated choices made that are explained below. This documentation does not cover that much of how Relay works in general, and it's not going to be enough if you're new or inexperienced with Relay. In those cases, please look at the Relay documentation in parallel (https://relay.dev) and come back here for finding out how to do things in Reason.

Workflow

Just a quick overview of the workflow of using ReasonRelay, primarily for people unfamiliar with Relay:

  1. Write GraphQL queries, fragments and mutations using [%relay] nodes.
  2. Run the ReasonRelay compiler. This finds the [%relay] nodes and generates artifacts (types etc) for each node.
  3. The ReasonRelay PPX transforms your [%relay] nodes into modules that you can use in your components.

So, write GraphQL -> Run the compiler -> Use the generated modules.

Setup

You'll need to create and expose and environment for Relay to use.

let fetchQuery = ...; // Check out RelayEnv.re in the examples folder for an example fetchQuery function using bs-fetch

let network = ReasonRelay.Network.makePromiseBased(fetchQuery); // Eventually we'll support makeObservableBased network too
let store = ReasonRelay.Store.make(ReasonRelay.RecordSource.make());

let environment = ReasonRelay.Environment.make(~network, ~store, ());

You'll then need to make sure you wrap your app with Relays context provider, feeding it your environment:

// Here, RelayEnv.re contains the environment setup, as in the above example
ReactDOMRe.renderToElementWithId(
  <ReasonRelay.Context.Provider environment=RelayEnv.environment>
    <App />
  </ReasonRelay.Context.Provider>,
  "app",
);

There, you're all set up!

Queries, fragments and mutations

All Relay/GraphQL operations must be defined inside [%relay] nodes. One exists for each supported feature: [%relay.query], [%relay.fragment], [%relay.mutation]. You assign that node to a module, like:

module MyQuery = [%relay.query {|
  query SomeQuery { ... }
|}];

The module name can be anything (MyQuery above). That's just a regular module. The GraphQL string you provide to the node however is subject to all the normal Relay validations, meaning the GraphQL string must follow Relay conventions of having a globally unique name matching the file name, following the operation/fragment naming convention, and so on.

Queries

Queries are defined using [%relay.query {| query YourQueryHere ... |}]:

module Query = [%relay.query {|
  query SomeOverviewQuery($id: ID!) {
    user(id: $id) {
      firstName
      friendCount
      ...SomeUserDisplayerComponent_user
    }
  }
|}];

This will be transformed into a module with roughly the following signature:

module Query = {
  // `use` is a React hook you can use in your components.
  let use = (~variables, ~dataFrom=?, ()) => queryResult;
  
  // `fetch` lets you fetch the query standalone, without going through a React component
  let fetch = (~environment: Environment.t, ~variables) => Js.Promise.t(response);
};

Use as hook: Query.use(~variables=..., ~dataFrom=...)

You can use the query as a hook inside of a React component:

[@react.component]
let make = (~userId) => {
  let query = Query.use(~variables={"id": userId}, ~dataFrom=StoreThenNetwork, ());
  
  switch (query) {
    | Loading => React.string("Loading...")
    | Error(err) => React.string("Error!")
    | Data(data) => switch (data##user |> Js.Nullable.toOption) {
      | Some(user) => React.string(user##firstName)
      | None => React.null
    | NoData => React.null 
}

Use independently: Query.fetch(...)

You can also use the query as you'd use fetchQuery from Relay, fetching the query outside of a React component. Doing that looks like this:

Query.fetch(~environment=SomeModuleWithMyEnvironment.environment, ~variables={"id": userId})
  |> Js.Promise.then_(res => switch(res##user |> Js.Nullable.toOption) {
    | Some(user) => {
      ...
      Js.Promise.resolve();
    }
  )

Fragments

HINT: Check out examples in this code repo for a complete example of using fragments and queries together.

Fragments are only exposed through React hooks. You define a fragment using [%relay.fragment {| fragment MyFragment_user on User { ... } |]. You then spread the fragment in your query just like normal. Using the fragment looks like this:

module BookFragment = [%relay.fragment
  {|
    fragment BookDisplayer_book on Book {
      title
      author
    }
  |}
];

/**
 * `book` passed to the component below is, just like "normal" Relay, 
 * and object from a query that contains the fragment spread.
 */

[@react.component]
let make = (~book as bookRef) => {
  let book = BookFragment.use(bookRef);
  ...

You can have as many fragments as you like in a component, but each must be defined separately. Fragments only expose a use hook, and nothing else.

Mutations

Mutations are defined using [%relay.mutation {| mutation YourMutationHere ... |}]:

module UpdateBookMutation = [%relay.mutation
  {|
    mutation BookEditorUpdateMutation($input: UpdateBookInput!) {
      updateBook(input: $input) {
        book {
          title
          author
        }
      }
    }
  |}
];

This will be transformed into a module with roughly the following signature:

module UpdateBookMutation = {
  // `use` is a React hook you can use in your components.
  let use = () => (mutate, mutationStatus);
  
  // `commitMutation` lets you commit the mutation from anywhere, not tied to a React component.
  let commitMutation= (~environment: Environment.t, ~variables, ~optimisticResponse, ~updater ...) => Js.Promise.t(response);
};

Everything that Relay supports (optimistic updates etc) is also supported by ReasonRelay, except for mutation updater configs. Please use the updater functionality for now when you need to update the store.

Use as hook: UpdateBookMutation.use()

You can use the mutation as a React hook:

[@react.component]
let make = (~bookId) => {
  let (mutate, mutationState) = UpdateBookMutation.use();
  
  ...
  mutate(
    ~variables={
      "input": {
        "clientMutationId": None,
        "id": bookId,
        "title": state.title,
        "author": state.author,
      },
    },

Use standalone: UpdateBookMutation.commitMutation(...)

Just like you'd use commitMutation from regular Relay, you can do the mutation from anywhere without needing to use a hook inside of a React component. commitMutation returns a promise that resolves with the mutation result.

UpdateBookMutation.commitMutation(
  ~environment=SomeModuleWithMyEnv.environment, 
  ~variables={
   "input": {
     "clientMutationId": None,
     "id": bookId,
     "title": state.title,
     "author": state.author,
   },
 },
) |> Js.Promise.then_(res => ...)

Refetching

Right now, there are no bindings to simplify refetching (RefetchContainer in Relay). We are waiting for the official Relay hooks/suspense-based API before we make an actual binding for refetching.

Meanwhile, doing a normal MyDefinedQuery.fetch(...) should suffice for some scenarios.

Connections/pagination

No bindings exist for PaginationContainer or a pagination hook either right now. Sadly there are no workarounds/alternatives (like with refetch) before we get the hooks/suspense based APIs from Relay itself.

Unions and Enums

Since Reason's type system is quite different to Flow/TypeScript, working with unions and enums works in a special way with ReasonReact.

Unions

Unions are fields in GraphQL that can return multiple GraphQL types. The field selections of each type is also potentially different for each used union. In Flow/TypeScript, this is handled by using union types that match on the __typename property that's always available on a union. Reason however does not have union types in the same sense. In order to get type-safety in Reason you'll therefore need to unwrap the union.

Imagine a GraphQL union that looks like this:

type SomeType {
  id: ID!
  name: String!
  age: Int!
}

type AnotherType {
  id: ID!
  count: Int!
  available: Boolean!
}

union SomeUnion = SomeType | AnotherType  

Now, the following Relay selection is made:

module Query = [%relay.query {|
  someUnionProp {
    __typename
    
    ... on SomeType {
      name
    }
    
    ... on AnotherType {
      count
    }
  }
|}

In this scenario, working with someUnionProp could be either a SomeType or AnotherType. With ReasonRelay, this will generate roughly the following (pseudo code):

module Union_someUnionProp = {
  type wrapped;
  let unwrap: wrapped => [ | `SomeType({. "name": string }) | `AnotherType({. "count": int }) | `UnmappedUnionMember ]
};

type response = {.
  "someUnionProp": Union_someUnionProp.wrapped
};

So, the actual node in the response is an abstract type wrapped that you'll need to unwrap, which will return a polymorphic variant with the data for each GraphQL type. All Union_someUnionPropName modules are available on the Query module where the Relay operation is defined. Unwrapping and using the data would then look like this:

switch (data##someUnionProp |> Query.Union_someUnionProp.unwrap) {
  | `SomeType(data) => React.string(data##name)
  | `AnotherType(data) => React.string("Count: " ++ string_of_int(data##count))
  | `UnmappedUnionMember => React.null
}

UnmappedUnionMember is a safety guard that you'll need to handle in case the server extends the union with something that you currently do not have code to handle.

Enums

In the GraphQL response, enums are just strings that follow a defined schema. However, Reason does not have string literal enums. This means you'll need to unwrap enums in order to work with them. The compiler generates a file called SchemaAssets.re containing types and utils to interact with all enums in your schema.

Here's how you unwrap enums to work with them:

// someEnum is an enum MyEnum, with possible values SOME_VALUE | ANOTHER_VALUE
module Query = [%relay.query {|
  query SomeQuery {
    someEnum
  }
|}];

You then work with the enum this way:

switch(data##someEnum |> SchemaAssets.Enum_MyEnum.unwrap) {
  | `SOME_VALUE => React.string("Some value")
  | `ANOTHER_VALUE => React.string("Another value")
  | `FUTURE_ADDED_VALUE__=> React.null
};

Similar to unions, enums always include a safety guard to force you to handle enum values that might be added after your code is deployed. In enums, that's an additional polymorphic variant FUTURE_ADDED_VALUE__ that's added to each enum and returned whenever there's an unknown enum value matched.

Enums as inputs/variables

If you want to use enums as input values, whether it's as a variable for querying or as an input for a mutation, you'll need to wrap the enum in order for it to be converted to something that can be sent to the server. wrap is a function that's generated for each of your enums. Example:

~variables={
  "myEnumValue": SchemaAssets.Enum_MyEnum.wrap(`SOME_VALUE)
}

Interacting with the store

ReasonRelay exposes a full interface to interacting with the store.

Updater functions for mutations

You can pass an updater function to mutations just like you'd normally do in Relay:

mutate(
~variables={
  ...
},
~updater=
  store => {
    // Open ReasonRelay to simplify using RecordSourceSelectorProxy and RecordProxy
    open ReasonRelay;

    let mutationRes =
      store->RecordSourceSelectorProxy.getRootField(
           ~fieldName="addBook",
         );
         
     ...
 },
(),
);

commitLocalUpdate

commitLocalUpdate is exposed for committing local updates, and can be used like this:

commitLocalUpdate(~environment=SomeModuleWithMyEnv.environment, ~updater=store => {
  open ReasonRelay;
  let root = store->RecordSourceSelectorProxy.getRoot;
  let someRootRecord = root->RecordProxy.getLinkedRecord(~name="someFieldName", ~arguments=None);
  
  switch (someRootRecord) {
    | Some(recordProxy) => {      
      recordProxy->RecordProxy.setValueString(~name="someFieldThatIsAString", ~value="New value", ~arguments=None);
    }
    | None => ...
  }
})

Note on getting and setting values from a RecordProxy

RecordProxy, the data type Relay exposes to update data in the store, saves field values as mixed types, meaning that recordProxy.getValue("someKey") in the JS version of Relay can return just about any type there is. However, Reason does not allow more than one type to be returned from a function call. So, in ReasonRelay, getValue and setValue is replaced with one function for each primitive type:

let someBoolValue = recordProxy->RecordProxy.getValueBool(~name="someFieldThatIsABoolean", ~arguments=None);
let someStringValue = recordProxy->RecordProxy.getValueString(~name="someFieldThatIsAString", ~arguments=None);
  
recordProxy->RecordProxy.setValueInt(~name="someFieldThatIsAnInt", ~value=1, ~arguments=None);
recordProxy->RecordProxy.setValueFloat(~name="someFieldThatIsAFloat", ~value=2.0, ~arguments=None);

// There's also functions for retrieving and setting arrays of primitives
let someStringArrayValue = recordProxy->RecordProxy.getValueStringArray(~name="someFieldThatIsAString", ~arguments=None);

Utilities, helpers and tips & tricks

Working with GraphQL can be a bit repetitive and verbose at times (I'm looking at you nullable connections). We think it's tedious enough that we want to maintain a set of utilities that makes it easier to work with ReasonRelay, even if the utilities technically are not specifically about Relay.

ReasonRelayUtils

ReasonRelay ships a module called ReasonRelayUtils that contains a bunch of functions that's designed to make your life working with Relay easier.

Working with connections

Connections are great, but their nested nullable structure make them quite a pain and in need of lots of boilerplate. More often than not what you want to do with a connection is to extract all non-null nodes from all edges and put them in a new array. ReasonRelayUtils contains two helpers to deal with that:

Imagine a response like this:

type response = {
  "someConnection": Js.Nullable.t({
    "edges": Js.Nullable.t(array(Js.Nullable.t({
      "node": Js.Nullable.t({
        "somePropOnNode": string
      })
    })))
  })
};

All you want is to get a list of the nodes, which is type node = { "somePropOnNode": string }. But in order to get that, you need to walk multiple levels of nullability, iterate arrays etc. Fear not! ReasonRelayUtils ships collectConnectionNodesFromNullable and collectConnectionNodes:

/**
 * Here we know that someConnection is nullable in itself too. 
 * This call to collectConnectionNodesFromNullable will result in let nodes: array({ "somePropOnNode": string }).
 * All nulls taken care of, and no edges. Yay!
 */ 
 
let nodes = ReasonRelayUtils.collectConnectionNodesFromNullable(response##someConnection);

/** 
 * There's also `collectConnectionNodes` that you can use on any object that is not nullable itself.
 * We're using collectConnectionNodesFromNullable because "someConnection" is modeled as nullable in the 
 * response above.
 */

Working with nullable arrays

There's also a simple helper that removes all nulls from a regular array called collectNodes and collectNodesFromNullable. It works on Js.Nullable.t(array(Js.Nullable.t('someTypeInArray))) (the fromNullable version) and gives you back a clean array of array('someTypeInArray).

Developing

Instructions coming soon.

0.14.1

3 years ago

0.15.0

3 years ago

0.14.0

3 years ago

0.13.0

3 years ago

0.12.1

3 years ago

0.12.0

3 years ago

0.11.0

4 years ago

0.10.0

4 years ago

0.9.2

4 years ago

0.9.1

4 years ago

0.9.0

4 years ago

0.8.3

4 years ago

0.8.2

4 years ago

0.8.1

4 years ago

0.8.0

4 years ago

0.7.0

4 years ago

0.6.0

4 years ago

0.5.3

4 years ago

0.5.2

4 years ago

0.5.1

4 years ago

0.5.0

4 years ago

0.4.4

5 years ago

0.4.3

5 years ago

0.4.2

5 years ago

0.4.1

5 years ago

0.4.0

5 years ago

0.1.3

5 years ago

0.1.2

5 years ago

0.1.1

5 years ago

0.1.0-alpha.2

5 years ago

0.1.0-alpha.1

5 years ago

0.1.0-alpha.0

5 years ago