ホームページ  >  記事  >  ウェブフロントエンド  >  NgRx の使用例、パート III: 意思決定

NgRx の使用例、パート III: 意思決定

PHPz
PHPzオリジナル
2024-08-16 06:17:06842ブラウズ

NgRx Use Cases, Part III: Decision-making

Unsplash の Hansjörg Keller によるオリジナルのカバー写真。

以前の記事では、NgRx によるアクセス制限とリストの処理について説明しました。今日は、主に NgRx エフェクト (ただし、少しのリデューサーとセレクター) を使用する Angular アプリ全体の意思決定に関する、より一般的な問題に取り組みます。この記事では、次のトピックについて説明します:

  • NgRx によるエラー処理
  • リダイレクトの処理
  • 読み込みデータの処理
  • ユーザーインタラクション

始めましょう!

NgRx によるエラー処理

エラー処理は誰もが嫌がるもの (そして忘れがちですが) ですが、誰もが本当に本当に必要としているものでもあります。 NgRx アプリでは、適切な注意を払って対処しないと、エラー処理の複雑さが実際に増加します。通常、NgRx のエラーは影響によって発生しますが、ほとんどのシナリオでは HTTP リクエストによって引き起こされます。

一般にエラーを処理するには、ローカルで処理するかグローバルに処理するという 2 つのアプローチがあります。ローカルで処理するということは、アプリの特定の部分で発生した非常に具体的なエラーに実際に対処することを意味します。たとえば、ユーザーがログインに失敗した場合、「問題が発生しました」のような一般的なエラー メッセージではなく、「無効なユーザー名またはパスワード」のような非常に具体的なエラー メッセージを表示したい場合があります。

一方、グローバルに処理するということは、ある意味、すべてのエラーを 1 つの「パイプライン」にまとめて、前に述べた非常に一般的なエラー メッセージを使用することを意味します。もちろん、どのアイデアがどのシナリオに適しているかについて議論を始めることもできますが、厳しい現実として、ほぼすべてのアプリが両方の世界を少しずつ必要とします。 何らかのエラーが発生した場合にエラー メッセージを表示するようにしたい (「問題が発生しました」は、サイレントエラーよりもまだ優れています)。また、 エラーが発生した場合には、いくつかの特定のアクションを実行したいと考えています。 🎜>いくつかの

エラー。両方のシナリオに対処しましょう。

NgRx による一般的なエラー処理


NgRx では、起こることはすべてアクションによってトリガーされます。エラーの発生を伴う可能性のあるアクティビティ (HTTP 呼び出し、WebSocket など) の場合、通常は 1 つのアクティビティに対して複数のアクションを作成します。

export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': props<{ error: string }>(),
  },
});

ご覧のとおり、API からデータをロードするだけの場合、3 つのアクションが必要です。特定のアプリケーションには、そのような「エラー」アクションや「成功」アクションが数百ではないにしても数十ある可能性があるため、それらのいずれかがディスパッチされた場合に一般的なエラー メッセージを表示したい場合があります。これは、エラー アクションのペイロードを標準化することで実現できます。たとえば、エラーを表すすべてのアクションに非常に具体的なプロパティを追加したい場合があります。この場合、アクション ペイロードに error プロパティが存在するだけで、アクションがエラーを表していることを知ることができます。


その後、エラーを表すすべてのアクションをサブスクライブし、一般的なエラー メッセージを表示できます。これは NgRx アプリでは非常に一般的なパターンであり、通常は「グローバル エラー処理」として理解されます。私たちの場合、すべてのアクションをサブスクライブし、ペイロードに error プロパティを持つアクションを除外することでこれを行うことができます:

export const handleErrors$ = createEffect(() => {
  const actions$ = inject(Actions);
  const notificationsService = inject(NotificationsService);
  return actions$.pipe(
    filter((action) => !!action.payload.error),
    tap((action) => {
      notificationsService.add({
        severity: 'error',
        summary: 'Error',
        detail,
      });
    }),
}, { functional: true, dispatch: false });


この場合、「エラー」アクションをディスパッチすると、同じ通知が表示されますが、カスタム メッセージも表示されます。エラーに対してアクション プロパティが作成されるアプローチを標準化することで、さらに前進することができます。ここに便利な小さなヘルパー関数があります:

export function errorProps(error: string) {
  return function() {
    return({error});
  };
}


これで、この関数を使用してアクションのエラー プロパティを作成できます。

export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': errorProps('Failed to load data'),
  },
});

これにより、タイプミスや混乱を避けるために、すべてのエラーが同じように見えます。次に、非常に特殊なエラーも処理できるようにこれを改善しましょう。

特定のエラーの処理

私たちの場合、いくつかの特定のケースに対して汎用エラー ハンドラーが動作する方法をカスタマイズできるようにしたい場合があります。できるようになりたい
  • 一般的なエラー メッセージを表示するかどうかを指示します
  • 事前定義されたデータを含むエラー ページにリダイレクト
  • ページ内にエラー通知を表示


最初のものから始めましょう。これを行うには、エラー アクション ペイロードに新しいプロパティを追加します:

export function errorProps(error: string, showNotififcation = true) {
    return function() {
        return({error, showNotification});
    };
}


これで、後でエフェクトに一般的な通知メッセージをスキップするように指示するアクションを作成できます。

export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': errorProps('Failed to load data', false),
  },
});


次に、これを反映するようにエフェクトを更新する必要があります:

export const handleErrors$ = createEffect(() => {
  const actions$ = inject(Actions);
  const notificationsService = inject(NotificationsService);
  return actions$.pipe(
    filter((action) => !!action.payload.error),
    tap((action) => {
      if (action.payload.showNotification) {
        notificationsService.add({
          severity: 'error',
          summary: 'Error',
          detail,
        });
      }
    }),
  );
}, { functional: true, dispatch: false });


フィルター演算子に showNotification プロパティのチェックを追加していないことに注意してください。これは、通知を表示すべきではないが、別のアクション (エラー ページへのリダイレクトなど) を実行する必要があるシナリオがあるためです。新しいパラメータをエラー アクションに追加して、これを正確に実行しましょう:

export function errorProps(error: string, showNotification = true, redirectTo?: string) {
  return function() {
    return({error, showNotification, redirectTo});
  };
}


これで、後でエフェクトにエラー ページにリダイレクトするように指示するアクションを作成できます。

export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': errorProps('Failed to load data', false, '/error'),
  },
});

Next, let's finalize our effect by adding a redirection to the error page if the redirectTo property is present in the action payload:

export const handleErrors$ = createEffect(() => {
  const actions$ = inject(Actions);
  const notificationsService = inject(NotificationsService);
  const router = inject(Router);
  return actions$.pipe(
    filter((action) => !!action.payload.error),
    tap((action) => {
      if (action.payload.showNotification) {
        notificationsService.add({
          severity: 'error',
          summary: 'Error',
          detail,
        });
      }
      if (action.payload.redirectTo) {
        router.navigateByUrl(action.payload.redirectTo);
      }
    }),
  );
}, { functional: true, dispatch: false });

And that is it to this. Of course, if we need something really custom for a particular error action, we can just write a completely separate effect to handle that. Sometimes, if we want to also do something in the UI in relation to an error, we can also add the error message (and any other data) to the store and use them via a selector anywhere.

Next, let us discuss loading data into our component, and several approaches to it.

Handling loading data

Before we proceed, we should first understand that the approaches listed in this section are not better or worse than one another. Instead, they are approaches for different situations, depending on what we want for our UX. Let's examine them step by step.

Selecting data in the component to use

The most straightforward way we can get some data (presumably from an API) is by just selecting it in the component. With the latest APIs, we can select a signal of our data and use it directly in the template. Here is a very simple example:

@Component({
  selector: 'app-my',
  template: `
    <div>
      <h1>Data</h1>
      <p>{{ data() }}</p>
    </div>
  `,
})
export class MyComponent {
  data = this.store.selectSignal(dataFeature.selectData);
}

Of course, in real life, we often need to deal with scenarios like loading, errors, and so on. In this case, our state might look like this:

export interface State {
  data: Data | null;
  loading: boolean;
  error: string | null;
}

If we are using the createFeature function to register our state, we can make use of the selectDataState function that the feature automatically creates for us. This will return the entire state with loading, error, and data properties. We can then use this in our component:

@Component({
  selector: 'app-my',
  template: `
    <div>
    @if (vm().loading) {
      <p>Loading...</p>
    }
    @if (vm().error) {
      <p>Error: {{ vm().error }}</p>
    } @else {
      <h1>Data</h1>
      <p>{{ vm().data }}</p>
    }
    </div>
  `,
})
export class MyComponent {
  vm = this.store.selectSignal(dataFeature.selectDataState);
}

This is very useful in most scenarios. However, sometimes we might not want to display the entire page if this important piece of data is not loaded. In Angular, this is commonly achieved with the use of routing resolvers, functions that return some Observables that routing waits for to emit before displaying a particular page. This is easy with the use of the HttpClient service, however, this becomes a bit complicated with NgRx (because we only make HTTP calls inside effects), resulting in lots of developers skipping resolvers entirely. However, there is an easy way to achieve this functionality. Let's build a simple resolver that utiliuzes the Store and the Actions Observable to know when the data is actually loaded:

export const dataResolver: ResolveFn<Data[]> = () => {  
  const store = inject(Store);
  const actions$ = inject(Actions);
  store.dispatch(DataActions.loadData());
  return store.select(dataFeature.selectData).pipe(
    skipUntil(actions.pipe(ofType(DataActions.loadDataSuccess))),
  );
}

Here, we first dispatch the action that actually initiates the HTTP call, then we just return the selected data from the store as an Observable, but with a catch - we tell it to wait until the action signaling that the data has been loaded is dispatched. Given that effects are guaranteed to run after reducers, this will ensure that the data is actually put into the store before the resolver returns it. We can then just pick this data up in our component:

@Component({
  selector: 'app-my',
  template: `
    <div>
      <h1>Data</h1>
      <p>{{ vm.data() }}</p>
    </div>
  `,
})
export class MyComponent {
  private readonly route = inject(ActivatedRoute);
  readonly vm = toSignal(this.route.data, {
    initialValue: null,
  }) as Signal<{data: Data}>;
}

We used ActivatedRoute instead of the Store because we already returned this data in the resolver. This makes our components even leaner - we don't even have to inject the Store and in unit testing, it can be often easier to mock the ActivatedRoute than the Store.

Finally, let's take a look at advanced decision-making with NgRx actions and effects, and see how this can help work with complex cases in large applications.

User interactions

NgRx is very useful when writing declarative code, as it allows us to just select the relevant state, and use it in our templates. However, sometimes, especially when dealing with third-party libraries, we need to perform some "imperative actions" which is tangentially related to our store. Consider this code which uses the Angular Material MatDialog service to open a confirmation dialog:

export class MyComponent {
  private readonly dialog = inject(MatDialog);
  private readonly store = inject(Store);

  openConfirmationDialog() {
    const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
      data: {
        title: 'Confirmation',
        message: 'Are you sure you want to do this?',
      },
    });

    dialogRef.componentInstance.confirm.subscribe(() => {
      this.store.dispatch(DataActions.deleteData());
    });

    dialogRef.componentInstance.cancel.subscribe(() => {
      dialogRef.close();
    });
  }
}

As we can see, there is a lot of imperative code in just this one method, there are two subscriptions, and they aren't even particularly simple (we just omitted unsubscription logic). Also, we must consider that in a normal application, we might have a dozen different places where the same confirmation dialog is used, with the only difference being the action that is performed when the user confirms/rejects.

Let's now approach this with an NgRx mindset, and try to create an action that can handle such a scenario, with callbacks as payloads.

export function confirmAction(callbacks: {confirm: () => void, reject: () => void}) {
  return function() {
    return({type: 'Open Confirmation Dialog', callbacks});
  };
}

Now, we can create an action that will later tell the effect to redirect to an error page:

export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Delete Data': confirmAction({
      confirm: () => {
        return({action: 'Delete Data Confirmed'});
      },
      reject: () => {
        return({action: 'Delete Data Rejected'});
      },
    }),
  },
});

Now, we can create an effect that will handle all such actions:

export const handleConfirmationDialog$ = createEffect(() => {
  const actions$ = inject(Actions);
  const dialog = inject(MatDialog);
  return actions$.pipe(
    ofType(DataActions.openConfirmationDialog),
    tap((action) => {
      const dialogRef = dialog.open(ConfirmationDialogComponent, {
        data: {
          title: 'Confirmation',
          message: 'Are you sure you want to do this?',
        },
      });

      dialogRef.componentInstance.confirm.subscribe(() => {
        action.payload.callbacks.confirm();
      });

      dialogRef.componentInstance.cancel.subscribe(() => {
        action.payload.callbacks.reject();
      });
    }),
  );
}, { functional: true, dispatch: false });

Finally, we can really simplify our component:

export class MyComponent {
  private readonly store = inject(Store);

  openConfirmationDialog() {
    this.store.dispatch(DataActions.openConfirmationDialog({
      confirm: () => {
        this.store.dispatch(DataActions.deleteData());
      },
      reject: () => {
        // Do nothing
      },
    }));
  }
}

And that is it. However... we might face a problem if we try to make our application as fine as possible. In NgRx, it is a good practice to keep everything serializable, which is a fancy way of saying "easily convertible to JSON". In the app configuration, it is possible to set specific options to help safeguard us from, for example, putting functions in the store. This is done with two options, strictStoreSerializability and strictActionSerializability.

export const config: ApplicationConfig = {
  providers: [
    provideStore({}, {
      runtimeChecks: {
        strictActionSerializability: true,
        strictStoreSerializability: true,
      },
    }),
};

This goes a long mile to help keep our applications maintainable and prevent hard-to-debug issues.

[!NOTE] You can read more about runtime checks in the NgRx docs.

However, if we make actions strictly serializable, our confirmAction action will not work with the callbacks we passed! So, what can we do about it? Well, the easiest way is to give it other actions for confirm/reject options to handle by the effect. Because the nested actions will also be required to be serializable, this will help us bring everything back to a workable state, an approach that I personally call "higher-order actions".

export function confirmAction(confirmAction: string, rejectAction: string, callbackActions: {
  confirm: ActionCreator<any, any>,
  reject: ActionCreator<any, any>
}) {
  return function() {
    return({type: 'Open Confirmation Dialog', callbackActions});
  };
}

Next, we need to do a major update to our effect:

export const handleConfirmationDialog$ = createEffect(() => {
  const actions$ = inject(Actions);
  const dialog = inject(MatDialog);
  return actions$.pipe(
    ofType(DataActions.openConfirmationDialog),
    map(({callbackActions}) => {
      const dialogRef = dialog.open(ConfirmationDialogComponent, {
        data: {
          title: 'Confirmation',
          message: 'Are you sure you want to do this?',
        },
      });

      return merge([
        dialogRef.componentInstance.confirm.pipe(
          map(() => callbackActions.confirm()),
        ),
        dialogRef.componentInstance.cancel.pipe(
          tap(() => dialogRef.close()),
          map(() => callbackActions.reject()),
        ),
      ])
    }),
  );
}, { functional: true, dispatch: false });

Let's deconstruct what goes on here.

  1. The ConfirmationDialogComponent exposes the confirm and cancel observables.
  2. We open a MatDialog with the ConfirmationDialogComponent and every time an action created by the confirmAction is dispatched, we subscribe to the confirm observable and dispatch the action that was passed to the confirmAction function.
  3. We also subscribe to the cancel observable and dispatch the action that was passed to the confirmAction function.
  4. We return a merge of the two observables so that when either of them emits, the whole effect will emit.

With this implementation, it is easy to perform complex decision-making just using several actions in a component:

export class MyComponent {
  private readonly store = inject(Store);

  openConfirmationDialog() {
    this.store.dispatch(DataActions.openConfirmationDialog({
      confirm: DataActions.deleteData,
      reject: DataActions.cancelDeleteData,
    }));
  }
}

And that is it. With higher-order actions, we can easily delegate decision-making further to other effects and reducers as necessary, making the component code as declarative as possible.

Conclusion

In this article, we covered a few approaches to complex logic in Angular applications utilizing NgRx. NgRx is a huge tapestry of opportunities, and often we can easily transform ugly code into something understandable and maintainable. Such approaches are underappreciated, and with this piece, I try to bring them forth to help developers improve their state management solutions.

以上がNgRx の使用例、パート III: 意思決定の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。