From e47d8188127f8d0b3cbdfa3abaffe0ef0d570f7a Mon Sep 17 00:00:00 2001 From: Jonathan Adshead Date: Tue, 1 Dec 2020 15:22:09 -0700 Subject: [PATCH 1/6] docs(recipes/making-an-api-call): update --- docs/recipes/Making-An-Api-Call.md | 277 ++++++++++++++++++++++++++++- docs/recipes/Module-Composition.md | 63 +++++++ docs/recipes/Post-To-Modules.md | 15 ++ docs/recipes/README.md | 4 +- 4 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 docs/recipes/Module-Composition.md create mode 100644 docs/recipes/Post-To-Modules.md diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index a03cf117e..a5b2e692c 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -4,15 +4,276 @@ # Making An API Call -Recipe is forthcoming. +> **TLDR**: Use [Fetchye](#fetchye) with the `fetchye-one-app` helpers. -## POST -To enable post set [`ONE_ENABLE_POST_TO_MODULE_ROUTES`](../api/server/Environment-Variables.md#ONE_ENABLE_POST_TO_MODULE_ROUTES) environment variable. -Request body must be either a JSON object or FormData of less than 15KB in size and is passed as props to your module. +Making API calls within a One App module has some additional considerations over a +traditional client side React application. -Supported media types: -- `application/json` -- `application/x-www-form-urlencoded` +A basic data fetching example in a client side React app might look like the following: +```jsx +const BooksModule = () => { + const [{ Books, isLoading, fetchError }, setData] = useState({}); -[☝️ Return To Top](#Making-An-Api-Call) + useEffect(() => { + const fetchBooks = async () => { + try { + setData({ Books, isLoading: true, fetchError: false }); + const response = await fetch('https://some-data-server.com/api/v1/books'); + if (response.ok) { + const newBooks = await response.json(); + setData({ Books: newBooks, isLoading: false }); + } else { + setData({ Books, isLoading: false, fetchError: true }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to fetch Books:', e); + setData({ Books, isLoading: false, fetchError: true }); + } + }; + fetchBooks(); + }, [id]); + + if (isLoading) { + return

Loading...

; + } + + if (fetchError) { + return

Error fetching Books

; + } + + return ( +
+

Books

+ +
+ ); +}; +``` + +This approach works for modules, however it will not fully benefit from server side rendering and, +if the url is called across multiple modules, will result in duplicated API calls. + +## Server Side data fetching + +One App will attempt to render your module on the server before sending the resulting HTML +back to client. If a module requires any asynchronous tasks to render, such as data fetching, +then these will need to be performed before the One App server renders the module. To do this you can use [`loadModuleData`](https://github.com/americanexpress/one-app/blob/main/docs/api/modules/Loading-Data.md#moduleholocronloadmoduledata). + +`loadModuleData` will be invoked before a module is rendered on the One App server. This happens when modules are loaded using [`ModuleRoute`](https://github.com/americanexpress/holocron/tree/main/packages/holocron-module-route) or [`composeModules`](https://github.com/americanexpress/holocron/blob/main/packages/holocron/docs/api/README.md#composemodules). `loadModuleData` will also be called on the client when the Holocron module mounts and receives props. + +> You can read more about how to use `ModuleRoute` in [Routing-And-Navigation](./Routing-And-Navigation.md) and `composeModules` in the [Module-Composition](./Module-Composition.md) recipes. + +Here is an example using `loadModuleData` to server side data fetch for the above example: + +```jsx +const loadModuleData = async ({ store, fetchClient, ownProps }) => { + store.dispatch({ type: 'FETCH_API' }); + try { + const response = await fetchClient('https://some-data-server.com/api/v1/books'); + if (response.ok) { + const data = await response.json(); + store.dispatch({ type: 'LOADED_API', data }); + } else { + store.dispatch({ type: 'FAILED_API' }); + } + } catch (e) { + store.dispatch({ type: 'FAILED_API' }); + } +}; + +Books.holocron = { + // Runs on both Server and Browser + loadModuleData, + reducer, +}; +``` + +The modules reducer would handle those dispatched actions so the module would be able to retrieve the data from the Redux store. + +## Duplicate Calls Across modules + +When adding response data to the redux store it can be tempting to try to \access this directly in other modules. This is **not** a recommended approach as you should aim to have modules as independent as possible. + +You could also choose to bubble up your data fetching the the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root module. + +## Fetchye + +[Fetchye](https://github.com/americanexpress/fetchye) brings a new, simplified method of making cached API calls. It makes use of React hooks to provide a simple API to enable data fetching with a centralized cache. Combined with the `fetchye-one-app` helpers it has minimal configuration and does not tightly couple a root module configuration to child modules. + +Below is a breakdown of the APIs used to integrate with One App: + +* [`useFetchye`](https://github.com/americanexpress/fetchye#usefetchye) from `fetchye` - A react hook responsible for dispatching an asynchronous fetch request to a given URL. +* [`OneFetchyeProvider`](https://github.com/americanexpress/fetchye#onefetchyeprovider) from `fetchye-one-app` - This is a react context provider which will ensure that any `useFetchye` calls will use the One App configuration. Think of this as the global config for your application. It is not required for `useFetchye` to work but it enables `useFetchye` to de-dupe requests and make use of a centralized cache. +* [`OneCache`](https://github.com/americanexpress/fetchye#onecache) from `fetchye-one-app` - This is a configured cache for use with One App modules. This is the cache which `OneFetchyeProvider` will always use. +* [`makeOneServerFetchye`](https://github.com/americanexpress/fetchye#makeoneserverfetchye) from `fetchye-one-app` - This helper creates a specialized fetch client for making requests on the One App server for server side rendering. + + +### Using `useFetchye` + +Install `fetchye`: + +```bash +npm i -S fetchye +``` + +Updating the first example to use `useFetchye` reduces the amount of boilerplate required for handling the request, loading and error states. + +```jsx +import { useFetchye } from 'fetchye'; + +const BooksModule = () => { + const { isLoading, data, error } = useFetchye('https://some-data-server.com/api/v1/books'); + const books = data && data.body; + + if (isLoading) { + return

Loading...

; + } + + if (error) { + return

Error fetching Books

; + } + + return ( +
+

Books

+ +
+ ); +}; +``` + +At this stage `useFetchye` will make the request but will not de-dupe or cache the response. + +> `useFetchye` has a default fetcher which will attempt to parse the response to JSON before returning `data` if you wish for a different approach you can supply a [custom fetcher](https://github.com/americanexpress/fetchye#custom-fetcher). + +### Enabling centralized cache + +To enable centralized caching the root module will need to add the `OneFetchyeProvider`. + +To do this install `fetchye-one-app` and, if not already installed in your root module, `fetchye`: + +```bash +npm i -S fetchye fetchye-one-app +``` + +Then at the top component of your root module add the `OneFetchyeProvider` and configure the reducer from `OneCache`: + +```jsx +import { combineReducers } from 'redux-immutable'; +import { OneFetchyeProvider, OneCache } from 'fetchye-one-app'; + +const MyModuleRoot = ({ children }) => ( +
+ { /* OneFetchyeProvider is configured to use OneCache */ } + + {/* Use your Router to supply children components containing useFetchye */} + {children} + +
+); + +// ... + +MyModuleRoot.holocron = { + name: 'my-module-root', + reducer: combineReducers({ + // ensure you scope the reducer under "fetchye", this is important + // to ensure that child modules can make use of the single cache + fetchye: OneCache().reducer, + // ... other reducers + }), +}; +``` + +Now every request made with `useFetchye` across your application will be de-duped and cached. You can now freely make requests with `useFetchye` anywhere the data is required and not worry about any unnecessary API calls. + +It is very **important** to note that the `OneCache().reducer` be set on your root module under the `fetchye` scope. If this is not done as shown above the provider will not be able to correctly make use of the cache. This convention ensures that any module using fetchye will correctly make use of the cache on both the client and server. If you wish to alter the configuration it will increase the chance for cache misses by other modules. + +### Fetching Data during SSR + +If we want to fetch the data on the server we can use `makeOneServerFetchye` to create a fetch client. This will directly update our Redux store which will be used to hydrate any data into our components when rendering on the server and form part the initial state of the fetchye cache on the client. + +Install `fetchye-one-app` in your module: + +```bash +npm i -S fetchye-one-app +``` + +Now we can update `loadModuleData` to use `makeOneServerFetchye` + +```jsx +import React from 'react'; +import { useFetchye } from 'fetchye'; +import { makeOneServerFetchye } from 'fetchye-one-app'; + +const bookUrl = 'https://some-data-server.com/api/v1/books'; + +const BooksModule = () => { + const { isLoading, data, error } = useFetchye(bookUrl); + const books = data && data.body; + + if (isLoading) { + return

Loading...

; + } + + if (error) { + return

Error fetching Books

; + } + + return ( +
+

Books

+ +
+ ); +}; + +// loadModuleData gets called before rendering on the server +// and during component mount and props update on the client +const loadModuleData = async ({ store: { dispatch, getState }, fetchClient }) => { + // We only need this to be called on the server as the useFetchye hook will + // take over in the client, so lets remove the unnecessary weight from our + // client bundle + if (!global.BROWSER) { + const fetchye = makeOneServerFetchye({ + // Redux store + store: { dispatch, getState }, + fetchClient, + }); + + // async/await fetchye has same arguments as useFetchye + // dispatches events into the server side Redux store + await fetchye(bookUrl); + } +}; + +BooksModule.holocron = { + loadModuleData, +}; + +export default BooksModule; +``` + +Please note that this low config approach relies on the conventions shown above. If the reducer or provider is not setup correctly in the root module you will not benefit from the caching. The `fetchye-one-app` helpers are designed to meet the majority of use cases and may not meet your requirements. It is possible to have a custom configuration using the `fetchye-redux-provider` and `fetchye-immutable-cache` however this could lead to cache misses and unutilized server side calls by modules not using the same configuration. + +[☝️ Return To Top](#Making-An-Api-Call) \ No newline at end of file diff --git a/docs/recipes/Module-Composition.md b/docs/recipes/Module-Composition.md new file mode 100644 index 000000000..1db9c5e87 --- /dev/null +++ b/docs/recipes/Module-Composition.md @@ -0,0 +1,63 @@ + +[👈 Return to Overview](./README.md) + + +# Module Composition + +A key part of working with One App is module composition. Modules can be rendered inside one another by either using `ModuleRoute` or `RenderModule`. + +## `ModuleRoute` + +`ModuleRoute` from `holocron-module-route` allows modules to dynamically load other modules +as a child route. This can be done on any module as routing is not limited to the route module: + +```jsx +ParentModule.childRoutes = () => [ + , +]; +``` + +When using ModuleRoute, additional props are passed through to the module via the `route` prop: + +```jsx +const ChildModule = ({ route: { greeting } } = {}) =>

{greeting}

; +``` + +## `RenderModule` + +Holocron's `RenderModule` provides an alternate method to rendering another module: + +```jsx +const ParentModule = () => ( +
+

I am the parent module

+ ; +
+); +``` + +To use RenderModule we need to ensure that the module bundle has been correctly +loaded into our client or server before it can be rendered. We can do this by +dispatching either `loadModule` or `composeModules` in our parent modules `loadModuleData`. + +```jsx +ParentModule.holocron = { + loadModuleData: async ({ store: { dispatch }, ownProps }) => { + await dispatch(composeModules([{ name: 'ChildModule' }])); + // or + // await dispatch(loadModule('ChildModule')); + }, +}; +``` + +Both `loadModule` and `composeModules` will ensure that the modules client or server bundle has been loaded and the module can be rendered, however there are some minor differences between the two. + +`loadModule` takes the modules name as a single argument and will only ensure that module bundle has been loaded so the module can be successfully rendered. + +`composeModules` takes an array of objects, each one contains the name of the module required to load. It will then perform `loadModule` on each of those modules and in addition ensure that every modules `loadModuleData` is invoked. This is very important when server side rendering your modules as `loadModuleData` executes the asynchronous tasks that might be required to correctly render a module on the server. + +[☝️ Return To Top](#Making-An-Api-Call) diff --git a/docs/recipes/Post-To-Modules.md b/docs/recipes/Post-To-Modules.md new file mode 100644 index 000000000..1c7158062 --- /dev/null +++ b/docs/recipes/Post-To-Modules.md @@ -0,0 +1,15 @@ + +[👈 Return to Overview](./README.md) + + +# POST To Module Routes + +To enable post set [`ONE_ENABLE_POST_TO_MODULE_ROUTES`](../api/server/Environment-Variables.md#ONE_ENABLE_POST_TO_MODULE_ROUTES) environment variable. +Request body must be either a JSON object or FormData of less than 15KB in size and is passed as props to your module. + +Supported media types: +- `application/json` +- `application/x-www-form-urlencoded` + + +[☝️ Return To Top](#POST) diff --git a/docs/recipes/README.md b/docs/recipes/README.md index 82af073b5..3b929da92 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -5,7 +5,8 @@ # 👩‍🍳 Recipes * [Adding Styles](./Adding-Styles.md) 🔨 -* [Making an API call](./Making-An-Api-Call.md) 📌 +* [Making an API call](./Making-An-Api-Call.md) +* [Module Composition](./Module-Composition.md) * [Mocking your API calls for Local Development](./Mocking-Api-Calls.md) * [Routing and Navigation](./Routing-And-Navigation.md) * [Code Splitting using Holocron](./Code-Splitting-Using-Holocron.md) @@ -18,6 +19,7 @@ * [Publishing Modules](Publishing-Modules.md) * [Progressive One App](PWA.md) * [Reporting Client Errors](./Reporting-Client-Errors.md) +* [Post to Modules](./Post-To-Modules.md) 🔨 > 🔨 = This guide is a work-in-progress. > 📌 = This guide needs to be written. From 0285bfabe9e770ff3a3236f7111a0199fcb228c0 Mon Sep 17 00:00:00 2001 From: Jonathan Adshead Date: Thu, 3 Dec 2020 10:54:38 -0700 Subject: [PATCH 2/6] docs(module-composition): use snakecase module name --- docs/recipes/Making-An-Api-Call.md | 6 ++++-- docs/recipes/Module-Composition.md | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index a5b2e692c..afa1620ba 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -100,7 +100,7 @@ The modules reducer would handle those dispatched actions so the module would be ## Duplicate Calls Across modules -When adding response data to the redux store it can be tempting to try to \access this directly in other modules. This is **not** a recommended approach as you should aim to have modules as independent as possible. +When adding response data to the redux store it can be tempting to try to access this directly in other modules. This is **not** a recommended approach as you should aim to have modules as independent as possible. You could also choose to bubble up your data fetching the the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root module. @@ -276,4 +276,6 @@ export default BooksModule; Please note that this low config approach relies on the conventions shown above. If the reducer or provider is not setup correctly in the root module you will not benefit from the caching. The `fetchye-one-app` helpers are designed to meet the majority of use cases and may not meet your requirements. It is possible to have a custom configuration using the `fetchye-redux-provider` and `fetchye-immutable-cache` however this could lead to cache misses and unutilized server side calls by modules not using the same configuration. -[☝️ Return To Top](#Making-An-Api-Call) \ No newline at end of file + +[☝️ Return To Top](#Making-An-Api-Call) + \ No newline at end of file diff --git a/docs/recipes/Module-Composition.md b/docs/recipes/Module-Composition.md index 1db9c5e87..88e2bd5f2 100644 --- a/docs/recipes/Module-Composition.md +++ b/docs/recipes/Module-Composition.md @@ -9,13 +9,13 @@ A key part of working with One App is module composition. Modules can be rendere ## `ModuleRoute` `ModuleRoute` from `holocron-module-route` allows modules to dynamically load other modules -as a child route. This can be done on any module as routing is not limited to the route module: +as a child route. This can be done on any module as routing is not limited to the root module: ```jsx ParentModule.childRoutes = () => [ , ]; @@ -35,29 +35,31 @@ Holocron's `RenderModule` provides an alternate method to rendering another modu const ParentModule = () => (

I am the parent module

- ; + ;
); ``` -To use RenderModule we need to ensure that the module bundle has been correctly -loaded into our client or server before it can be rendered. We can do this by +To use `RenderModule` we need to ensure that the module bundle has been correctly +loaded into our client before it can be rendered. We can do this by dispatching either `loadModule` or `composeModules` in our parent modules `loadModuleData`. ```jsx ParentModule.holocron = { loadModuleData: async ({ store: { dispatch }, ownProps }) => { - await dispatch(composeModules([{ name: 'ChildModule' }])); + await dispatch(composeModules([{ name: 'child-module' }])); // or - // await dispatch(loadModule('ChildModule')); + // await dispatch(loadModule('child-module')); }, }; ``` -Both `loadModule` and `composeModules` will ensure that the modules client or server bundle has been loaded and the module can be rendered, however there are some minor differences between the two. +Both `loadModule` and `composeModules` will ensure that the modules client bundle is loaded and the module can be rendered, however there are some minor differences between the two. -`loadModule` takes the modules name as a single argument and will only ensure that module bundle has been loaded so the module can be successfully rendered. +`loadModule` takes the modules name as a single argument and will only ensure that a modules client bundle is loaded so the module can be rendered on the client. `composeModules` takes an array of objects, each one contains the name of the module required to load. It will then perform `loadModule` on each of those modules and in addition ensure that every modules `loadModuleData` is invoked. This is very important when server side rendering your modules as `loadModuleData` executes the asynchronous tasks that might be required to correctly render a module on the server. + [☝️ Return To Top](#Making-An-Api-Call) + \ No newline at end of file From f79b2dbe0d7434014a2f6382fb74b562f427b245 Mon Sep 17 00:00:00 2001 From: Jonny Adshead Date: Fri, 4 Dec 2020 09:09:03 -0700 Subject: [PATCH 3/6] docs(api-call): grammar Co-authored-by: Ruben Casas --- docs/recipes/Making-An-Api-Call.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index afa1620ba..d3fc7047a 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -102,7 +102,7 @@ The modules reducer would handle those dispatched actions so the module would be When adding response data to the redux store it can be tempting to try to access this directly in other modules. This is **not** a recommended approach as you should aim to have modules as independent as possible. -You could also choose to bubble up your data fetching the the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root module. +You could also choose to bubble up your data fetching the the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root modules. ## Fetchye @@ -278,4 +278,4 @@ Please note that this low config approach relies on the conventions shown above. [☝️ Return To Top](#Making-An-Api-Call) - \ No newline at end of file + From 1f1ed0ff7bbedb2076061496027324db6e557316 Mon Sep 17 00:00:00 2001 From: Jonny Adshead Date: Tue, 8 Dec 2020 13:02:05 -0700 Subject: [PATCH 4/6] fix(api-call): examples Co-authored-by: Mike Tobia --- docs/recipes/Making-An-Api-Call.md | 2 +- docs/recipes/Module-Composition.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index d3fc7047a..b6773e489 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -102,7 +102,7 @@ The modules reducer would handle those dispatched actions so the module would be When adding response data to the redux store it can be tempting to try to access this directly in other modules. This is **not** a recommended approach as you should aim to have modules as independent as possible. -You could also choose to bubble up your data fetching the the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root modules. +You could also choose to bubble up your data fetching to the root module and pass down the data as props, which is a common approach with React applications, however this will result in closer coupling between child and root modules. ## Fetchye diff --git a/docs/recipes/Module-Composition.md b/docs/recipes/Module-Composition.md index 88e2bd5f2..73313e328 100644 --- a/docs/recipes/Module-Composition.md +++ b/docs/recipes/Module-Composition.md @@ -35,7 +35,7 @@ Holocron's `RenderModule` provides an alternate method to rendering another modu const ParentModule = () => (

I am the parent module

- ; +
); ``` @@ -62,4 +62,4 @@ Both `loadModule` and `composeModules` will ensure that the modules client bundl [☝️ Return To Top](#Making-An-Api-Call) - \ No newline at end of file + From e858bf4cd0a94d926d0e51e9413e078e526b89a2 Mon Sep 17 00:00:00 2001 From: Jonathan Adshead Date: Tue, 8 Dec 2020 13:08:38 -0700 Subject: [PATCH 5/6] docs(making-an-api-call): fix examples --- docs/recipes/Making-An-Api-Call.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index b6773e489..9096649ba 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -13,27 +13,27 @@ A basic data fetching example in a client side React app might look like the fol ```jsx const BooksModule = () => { - const [{ Books, isLoading, fetchError }, setData] = useState({}); + const [{ books, isLoading, fetchError }, setData] = useState({}); useEffect(() => { const fetchBooks = async () => { try { - setData({ Books, isLoading: true, fetchError: false }); + setData({ books, isLoading: true, fetchError: false }); const response = await fetch('https://some-data-server.com/api/v1/books'); if (response.ok) { const newBooks = await response.json(); - setData({ Books: newBooks, isLoading: false }); + setData({ books: newBooks, isLoading: false }); } else { - setData({ Books, isLoading: false, fetchError: true }); + setData({ books, isLoading: false, fetchError: true }); } } catch (e) { // eslint-disable-next-line no-console console.error('Failed to fetch Books:', e); - setData({ Books, isLoading: false, fetchError: true }); + setData({ books, isLoading: false, fetchError: true }); } }; fetchBooks(); - }, [id]); + }); if (isLoading) { return

Loading...

; From 42e44c85c4e33a6179c2632f4c97a1f480e0ab73 Mon Sep 17 00:00:00 2001 From: Jonny Adshead Date: Wed, 9 Dec 2020 10:22:56 -0700 Subject: [PATCH 6/6] docs(api-call): add fetch link Co-authored-by: Nelly Kiboi --- docs/recipes/Making-An-Api-Call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/recipes/Making-An-Api-Call.md b/docs/recipes/Making-An-Api-Call.md index 9096649ba..9d379f93e 100644 --- a/docs/recipes/Making-An-Api-Call.md +++ b/docs/recipes/Making-An-Api-Call.md @@ -9,7 +9,7 @@ Making API calls within a One App module has some additional considerations over a traditional client side React application. -A basic data fetching example in a client side React app might look like the following: +A basic data fetching example in a client side React app using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) might look like the following: ```jsx const BooksModule = () => {