HenrikJoreteg / redux-bundler

Compose a Redux store out of smaller bundles of functionality.
https://reduxbundler.com
583 stars 46 forks source link

How do you pass props to selectors when they aren't in the route params #54

Closed mark0978 closed 4 years ago

mark0978 commented 5 years ago
   <StoryLinkFromId id={something}>DisplayText</StoryLinkFromId>

I'd like to be able to use the id to grab the story from the state, and at the same time trigger a load of that story if it does not exist locally. I'm having a hard time figuring out how to do this without passing the prop.id down into a selector either as a prop or an argument. How do I cause an auto fetch based on the prop.id in this case?

Stories are fetched based on the URL, but they contain links to other stories/chapters and those links might need to fetch a story as well to make a prettier link (using the story title). I'm trying to keep all the auto fetch stuff in the store and NOT in the components but I'm having a devil of time. I've done things like:

connect('selectStories', StoryLinkFromId)

and then I can do

const story = stories[id];
if(stories[id]) { work with the story data here }
else { show a stand in while it loads }

What I want is the detection down in the store that stories[id] is undefined and a reaction within the store (similar to what loads the story to display) would cause it to load, but since I can never get props.id down into the select mechanism I don't have a good way to trigger this auto loading...

mark0978 commented 5 years ago

This is the best I've come up with so far, but I'm not really all that thrilled with the component dealing with the fetch....

function _StoryLinkFromId(props) {
  // When displaying inspired_by or next_chapter, sometimes all we have is a link, since these
  //  are likely destinations we will go ahead and try to load them so they are ready for the
  //  user.  In the meantime we just show the url since it is probably below the fold anyway
  //  and once we have the actual story in the cache, we replace that link with the normal one
  const story = props.stories.cache[props.id];
  if (story) {
    return <StoryLink story={story} />;
  }

  props.doFetchStory(props.id);
  const url = makeStoryUrl(props.id);
  return (
    <NavLink to={url} className="story__link_id">
      {props.alternate_text || url}
    </NavLink>
  );
}
const StoryLinkFromId = connect(
  'selectStories',
  'doFetchStory',
  _StoryLinkFromId
);

This is on top of exposing the structure of the story cache to the component......

HenrikJoreteg commented 5 years ago

Hey @mark0978!

This is a variation of a common question and yeah, it's a bit hairy to do this in a way that lets you keep fetching logic out of the component. But, personally, I think it's worth it. Especially when combined with caching you can make it so that you have an growing cache of fetched stories. Additionally, if you've already fetched and added items to your story cache you'd already have them by the time a user clicked 'em.

The main story ID is in the URL already, it sounds like. So first you need a selector that grabs the story Id from the URL, and one that grabs the corresponding story ID from cache. Something like this:

selectActiveStoryId: createSelector(
  'selectUrlParams',
  params => params.storyId
),
// i often make one that returns the "raw" story that object including metadata about
// whether I'm currently fetching it or not, it may not have data yet:
// it may return something like: {loading: true, data: null}
// this can be used in a reactor to determine whether or not we need to
// fetch it.
selectActiveStoryRaw:  createSelector(
  'selectCachedStoriesById', // something that returns an object of cached stories keyed by story id
  'selectActiveStoryId',
  (stories, id) => stories[id]
),
// now we can have a selector that only returns the data of the active story if we have it
selectActiveStoryData: createSelector(
  'selectActiveStoryRaw'
  storyObjectRaw => storyObjectRaw.data
),
// extract other stories we may need to fetch
selectAllActiveStoryIds: createSelector(
  'selectActiveStoryData',
  (data, storiesById) => {
    if (!data) {
      return null
    }
    return data.subStories.map(item => item.id)
  }
),
// now we can write a reactor that fetches the ones
// we don't already have and aren't already loading
reactFetchSubStories: createSelector(
  'selectAllActiveStoryIds',
  'selectCachedStoriesById'
  (ids, storyObjects) => {
    if (!ids || !ids.length) {
      return 
    }
    const idsToFetch = ids.filter(id => {
      const matchingStoryObj = storyObjects[id]
      if (!matchingStoryObject) {
        return true
      }
      return !matchingStoryObject.data && !matchingStoryObject.loading
    })
    if (idsToFetch.length) {
      // it's just important that the action creator here immediately dispatches a 
      //  "started" action that creates an entry in the reducer keyed by id with "loading: true" 
      return { actionCreator: 'doFetchStories', args: [idsToFetch] }
    }
  }
)

// at the point where all this exists you can write a single selector for the
// view that just combines all the available data (including sub stories)
selectActiveStory: createSelector(
  'selectActiveStoryRaw',
  'selectCachedStoriesById',
  (activeStoryRaw, cachedStories) => {
    if (!activeStoryRaw) {
      return null
    }
    // you could do this to show a loading state
    // for the root story if you wanted
    if (activeStoryRaw.loading)  {
      return {loading: true}
    }
    // extract all the sub stories
    // note that some of these may be .loading === true too
    // so if you want you should show that in the UI
    // they would just pop in once loaded
    const subStories = activeStoryRaw.data.subStories.map(id  => cachedStories[id])
    return Object.assign({}, activeStoryRaw.data, {subStories})
)

After all that, now you can write a stateless view component that just uses selectActiveStory to render all this stuff.

HenrikJoreteg commented 4 years ago

I'm going to close this one. Just doing some cleanup