Passed
Push — feature/data-request-hooks ( 8646f4...d9a1fc )
by Tristan
05:36
created

singleResourceHook.ts ➔ useResource   F

Complexity

Conditions 15

Size

Total Lines 102
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 83
dl 0
loc 102
c 0
b 0
f 0
rs 2.6563
cc 15

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like singleResourceHook.ts ➔ useResource often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import { Reducer, useCallback, useEffect, useReducer, useRef } from "react";
2
import {
3
  FetchError,
4
  getRequest,
5
  processJsonResponse,
6
} from "../../helpers/httpRequests";
7
import { identity } from "../../helpers/queries";
8
import { Json, ResourceStatus } from "./types";
9
10
export interface ResourceState<T> {
11
  value: T;
12
  status: ResourceStatus;
13
  pendingCount: number;
14
  error: Error | FetchError | undefined;
15
}
16
17
export enum ActionTypes {
18
  GetStart = "GET_START",
19
  GetFulfill = "GET_FULFILL",
20
  GetReject = "GET_REJECT",
21
22
  UpdateStart = "UPDATE_START",
23
  UpdateFulfill = "UPDATE_FULFILL",
24
  UpdateReject = "UPDATE_REJECT",
25
}
26
27
export type GetStartAction = { type: ActionTypes.GetStart };
28
export type GetFulfillAction<T> = {
29
  type: ActionTypes.GetFulfill;
30
  payload: T;
31
};
32
export type GetRejectAction = {
33
  type: ActionTypes.GetReject;
34
  payload: Error | FetchError;
35
};
36
37
export type UpdateStartAction<T> = {
38
  type: ActionTypes.UpdateStart;
39
  meta: { item: T };
40
};
41
export type UpdateFulfillAction<T> = {
42
  type: ActionTypes.UpdateFulfill;
43
  payload: T;
44
  meta: { item: T };
45
};
46
export type UpdateRejectAction<T> = {
47
  type: ActionTypes.UpdateReject;
48
  payload: Error | FetchError;
49
  meta: { item: T };
50
};
51
export type AsyncAction<T> =
52
  | GetStartAction
53
  | GetFulfillAction<T>
54
  | GetRejectAction
55
  | UpdateStartAction<T>
56
  | UpdateFulfillAction<T>
57
  | UpdateRejectAction<T>;
58
59
/**
60
 * Decrement the number if it above zero, else return 0.
61
 * This helps to avoid some pathological edge cases where pendingCount becomes permanently bugged.
62
 * @param num
63
 */
64
function decrement(num: number): number {
65
  return num <= 0 ? 0 : num - 1;
66
}
67
68
export function initialState<T>(initialValue: T): ResourceState<T> {
69
  return {
70
    value: initialValue,
71
    status: "initial",
72
    pendingCount: 0,
73
    error: undefined,
74
  };
75
}
76
77
export function reducer<T>(
78
  state: ResourceState<T>,
79
  action: AsyncAction<T>,
80
): ResourceState<T> {
81
  switch (action.type) {
82
    case ActionTypes.GetStart:
83
    case ActionTypes.UpdateStart: // TODO: For now GET and UPDATE actions can be treated the same. If we want to add optimistic updates, this can change.
84
      return {
85
        value: state.value,
86
        status: "pending",
87
        pendingCount: state.pendingCount + 1,
88
        error: undefined,
89
      };
90
    case ActionTypes.GetFulfill:
91
    case ActionTypes.UpdateFulfill:
92
      return {
93
        value: action.payload,
94
        status: state.pendingCount <= 1 ? "fulfilled" : "pending",
95
        pendingCount: decrement(state.pendingCount),
96
        error: undefined,
97
      };
98
    case ActionTypes.GetReject:
99
    case ActionTypes.UpdateReject:
100
      return {
101
        value: state.value,
102
        status: state.pendingCount <= 1 ? "rejected" : "pending",
103
        pendingCount: decrement(state.pendingCount),
104
        error: action.payload,
105
      };
106
    default:
107
      return state;
108
  }
109
}
110
111
function doNothing(): void {
112
  /* do nothing */
113
}
114
115
export function useResource<T>(
116
  endpoint: string,
117
  initialValue: T,
118
  overrides?: {
119
    parseResponse?: (response: Json) => T; // Defaults to the identity function.
120
    skipInitialFetch?: boolean; // Defaults to false. Override if you want to keep the initialValue until refresh is called manually.
121
    handleError?: (error: Error | FetchError) => void; // In addition to using the error returned by the hook, you may provide a callback called on every new error.
122
  },
123
): {
124
  value: T;
125
  status: ResourceStatus;
126
  error: undefined | Error | FetchError;
127
  update: (newValue: T) => Promise<T>;
128
  refresh: () => Promise<T>;
129
} {
130
  const parseResponse = overrides?.parseResponse ?? identity;
131
  const doInitialRefresh = overrides?.skipInitialFetch !== true;
132
  const handleError = overrides?.handleError ?? doNothing;
133
  const isSubscribed = useRef(true);
134
135
  const [state, dispatch] = useReducer<
136
    Reducer<ResourceState<T>, AsyncAction<T>>
137
  >(reducer, initialState(initialValue));
138
139
  const refresh = useCallback(async (): Promise<T> => {
140
    dispatch({ type: ActionTypes.GetStart });
141
    let json: Json;
142
    try {
143
      json = await getRequest(endpoint).then(processJsonResponse);
144
    } catch (error) {
145
      if (isSubscribed.current) {
146
        dispatch({
147
          type: ActionTypes.GetReject,
148
          payload: error,
149
        });
150
        handleError(error);
151
      }
152
      throw error;
153
    }
154
    const responseValue = parseResponse(json) as T;
155
    if (isSubscribed.current) {
156
      dispatch({
157
        type: ActionTypes.GetFulfill,
158
        payload: responseValue,
159
      });
160
    }
161
    return responseValue;
162
  }, [endpoint, parseResponse, handleError]);
163
164
  const update = useCallback(
165
    async (newValue): Promise<T> => {
166
      dispatch({ type: ActionTypes.UpdateStart, meta: { item: newValue } });
167
      let json: Json;
168
      try {
169
        json = await getRequest(endpoint).then(processJsonResponse);
170
      } catch (error) {
171
        if (isSubscribed.current) {
172
          dispatch({
173
            type: ActionTypes.UpdateReject,
174
            payload: error,
175
            meta: { item: newValue },
176
          });
177
          handleError(error);
178
        }
179
        throw error;
180
      }
181
      const responseValue = parseResponse(json) as T;
182
      if (isSubscribed.current) {
183
        dispatch({
184
          type: ActionTypes.UpdateFulfill,
185
          payload: responseValue,
186
          meta: { item: newValue },
187
        });
188
      }
189
      return responseValue;
190
    },
191
    [endpoint, parseResponse, handleError],
192
  );
193
194
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
195
  // Changing doInitialRefresh after the first run (or the refresh function) should not cause this to rerun.
196
  useEffect(() => {
197
    if (doInitialRefresh) {
198
      refresh().catch(doNothing);
199
    }
200
    // eslint-disable-next-line react-hooks/exhaustive-deps
201
  }, [endpoint]);
202
203
  // Unsubscribe from promises when this hook is unmounted.
204
  useEffect(() => {
205
    return (): void => {
206
      isSubscribed.current = false;
207
    };
208
  }, []);
209
210
  return {
211
    value: state.value,
212
    status: state.status,
213
    error: state.error,
214
    update,
215
    refresh,
216
  };
217
}
218
219
export default useResource;
220